Compare commits

...

147 Commits

Author SHA1 Message Date
Mike Griese
ea42344f03 Merge branch 'dev/migrie/40113/move-context-message' into dev/migrie/selfhost/0.93-003 2025-07-28 10:03:23 -05:00
Mike Griese
bb5e6da47b PRE-MERGE #40640 2025-07-28 10:03:17 -05:00
Mike Griese
da42871023 fine merge conflict fine 2025-07-28 10:01:46 -05:00
Mike Griese
ab18a1f91d Merge remote-tracking branch 'origin/main' into dev/migrie/40113/move-context-message 2025-07-28 09:57:59 -05:00
Mike Griese
3460be464f Merge remote-tracking branch 'origin/main' into feature/40616-replace-tapped-event-with-click-on-button 2025-07-28 09:57:35 -05:00
Mike Griese
c286d01820 PRE-MERGE #40513 2025-07-28 09:18:50 -05:00
Mike Griese
433168b945 PRE-MERGE #40825 2025-07-28 09:18:44 -05:00
Mike Griese
84e928656f PRE-MERGE #40815 2025-07-28 09:18:41 -05:00
Mike Griese
657b281001 PRE-MERGE #40716 2025-07-28 09:18:39 -05:00
Mike Griese
c93e8183e5 PRE-MERGE #40791 2025-07-28 09:18:35 -05:00
Mike Griese
7c28f0f98b PRE-MERGE #40768 2025-07-28 09:18:33 -05:00
Mike Griese
7d1f84ccb4 PRE-MERGE #40761 2025-07-28 09:18:30 -05:00
Mike Griese
a7cac62b5f PRE-MERGE #40752 2025-07-28 09:18:27 -05:00
Mike Griese
087ebb1897 PRE-MERGE #40758 2025-07-28 09:18:16 -05:00
Mike Griese
c22fea859b PRE-MERGE #40847 2025-07-28 09:18:08 -05:00
Mike Griese
eba71baea6 PRE-MERGE #40777 2025-07-28 09:17:35 -05:00
Mike Griese
d6a61da584 Yea this can move too 2025-07-28 09:09:57 -05:00
Jiří Polášek
efb3c307f9 Merge branch 'main' into feature/38260-bandaid-iconcacheservice-exception-handling 2025-07-28 15:58:05 +02:00
Mike Griese
78674bad64 Merge remote-tracking branch 'origin/main' into dev/migrie/40113/move-context-message 2025-07-28 08:48:51 -05:00
Mike Griese
3aeb50249e Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-with-files-2 2025-07-28 08:47:52 -05:00
Mike Griese
7c35093675 Merge remote-tracking branch 'origin/main' into list-announce-screen-reader 2025-07-28 08:39:07 -05:00
Mike Griese
57095c4d8c CmdPal: fix handling form submits
Yea this was real dumb.

I removed the `HandleCommandResultMessage` handler from `ShellPage`,
and never put it on `ShellViewModel`. Just first-grade kind of mistake.

Regressed in #40479
re: #40113
2025-07-28 06:36:55 -05:00
Jiří Polášek
3fa2f02da2 Add error handling to extension disposal
Ensure that errors encountered while sending the extension disposal signal are handled gracefully. If an error occurs when disposing of a particular extension, continue signaling the remaining extensions rather than halting the entire process. This prevents a single failure from interrupting the disposal chain and improves overall robustness.
2025-07-26 13:01:58 +02:00
Mike Griese
44abb98fba CmdPal: Remove vestigial try/catch
This was added in #38040 but appears to be vestigial now.

RE: #40113
2025-07-25 14:54:33 -05:00
Mike Griese
28c5ab709a CmdPal: Move the OpenContextMenuMessage into the UI project
I just blindly moved all the messages. But _this_ one really makes more
sense as a UI message. It's got framework elements. It us used to
actually open a UI element. The whole thing is very UI specific.

re: #40113
2025-07-24 05:52:19 -05:00
Jiří Polášek
616da6ae5e Poor mans synchronization of reloading commands (hotfix, not permanent) 2025-07-24 00:44:07 +02:00
Mike Griese
3a124c02ee and in the context menu too 2025-07-23 16:46:29 -05:00
Mike Griese
76954b3c41 cmdpal: move kb shortcut handling to PreviewKeyDown
This lets things like C-S-c work in the text box

Closes #40174
2025-07-23 16:29:15 -05:00
Jiří Polášek
6d18c453ae Typo. Spellchecker is the new Skynet! 2025-07-23 16:09:12 +02:00
Jiří Polášek
d9f530cdf7 Extend the lock to cover the entire operation to prevent a stale clone from rewriting the TopLevelCommands and fix problem with duplication 2025-07-23 15:51:17 +02:00
Mike Griese
31f74040aa Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-23 06:52:09 -05:00
Mike Griese
b0a500e875 Merge branch 'main' into dev/migrie/f/run-page-2-with-history 2025-07-23 06:51:50 -05:00
Mike Griese
2e2e060f4e context shortcuts are good too 2025-07-23 06:49:54 -05:00
Mike Griese
35ed9f5b56 file context items on commands too 2025-07-23 06:38:08 -05:00
Mike Griese
04d626a053 STASH: start refactoring file search commands to toolkit 2025-07-22 16:39:55 -05:00
Jessica Dene Earley-Cha
6ddf6064e1 add AutomationNotification for screen readers 2025-07-22 13:28:24 -07:00
Mike Griese
b111899ac0 spel 2025-07-22 15:14:27 -05:00
Mike Griese
e6e0f6d541 Revert "MAIN: fix this"
This reverts commit b79cf60d94.
2025-07-22 15:12:32 -05:00
Mike Griese
68c364f75e Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-22 14:54:04 -05:00
Mike Griese
7649c16d2e dead comment 2025-07-22 14:53:40 -05:00
Mike Griese
66489acaac Merge commit 'd62eeb39b' into dev/migrie/f/run-page-2-with-history 2025-07-22 14:49:39 -05:00
Mike Griese
d62eeb39b9 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-22 14:48:22 -05:00
Mike Griese
87b79a7ee8 Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-22 10:28:25 -05:00
Mike Griese
367504537d suggestions too 2025-07-22 10:28:00 -05:00
Jiří Polášek
9b6794f359 Adds lock around access to TopLevelCommandManager 2025-07-22 15:47:04 +02:00
Mike Griese
0cfb321b9b Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-22 06:56:45 -05:00
Mike Griese
4b0534acf8 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-22 06:56:29 -05:00
Jiří Polášek
75810c439d Fix XAML formatting 2025-07-21 22:07:38 +02:00
Jiří Polášek
58ec692b66 Handle exceptions when enqueuing callbacks to UI thread in IconCacheService
Handle exceptions thrown in TryEnqueue callbacks so they don’t crash the app (as they cannot be caught by the global exception handler). Any exceptions are now returned to the caller for handling. Additionally, a failure to enqueue the operation onto the dispatcher will also result in an exception.

This is not a breaking change, as exceptions only propagate within the class and do not affect external callers.

Ref: #38260
2025-07-19 19:32:11 +02:00
Jiří Polášek
e8077a42e1 Remove background from items in the status messages list (as requested by niels9001) 2025-07-18 18:01:00 +02:00
Jiří Polášek
c5247c831a Remove attributes with default values from XAML stack panel 2025-07-18 17:36:51 +02:00
Jiří Polášek
d6438a6f0b Update IconGrid margin to account for the inner button border and padding 2025-07-18 17:31:45 +02:00
Jiří Polášek
d5b8df24c5 Fix XAML style 2025-07-18 00:56:50 +02:00
Jiří Polášek
f820bca84f Replace status message badge with a button
The previous implementation used a visual badge inside a tappable grid element. However, the grid was not focusable or operable via keyboard, reducing accessibility. Replacing it with a proper button improves keyboard navigation and ensures consistent interaction behavior across input methods.
2025-07-17 14:18:17 +02:00
Jiří Polášek
ee4f75dbb8 Replace RightTapped with ContextRequested event
ContextRequested event covers other input gestures, like keyboard Context menu key or Shift+F10.
2025-07-17 13:27:37 +02:00
Jiří Polášek
64fe19c750 Replace Tapped event with Click on button in CmdPal
Click event on WinUI buttons handle more that just click and is more versatile that Tapped event. When you tap a Button with a finger or stylus, or press a left mouse button while the pointer is over it, the button raises the Click event. If a button has keyboard focus, pressing the Enter key or the Spacebar key also raises the Click event.
2025-07-16 20:02:39 +02:00
Mike Griese
b79cf60d94 MAIN: fix this 2025-07-16 09:33:41 -05:00
Mike Griese
74efb00f40 Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-16 06:57:32 -05:00
Mike Griese
bf83bbd94d Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-16 06:55:50 -05:00
Mike Griese
12b7555006 dead code 2025-07-16 06:54:00 -05:00
Mike Griese
77a0d77471 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-16 06:53:30 -05:00
Jiří Polášek
a2cb164a74 Merge branch 'main' into feature/39167-cmdpal-if-you-update-the-command-after-changing-the-title-of-a-fallback-item-we-won-t-hide-it-correctly 2025-07-16 00:32:25 +02:00
Mike Griese
9a3667c91f Merge branch 'dev/migrie/f/run-page-2-with-history' into dev/migrie/f/bookmark-exes 2025-07-15 13:15:59 -05:00
Mike Griese
45145cf1a4 Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-15 13:15:04 -05:00
Mike Griese
f758907850 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-15 13:14:40 -05:00
Mike Griese
9323598fed don't add to the end of the list
honestly, kinda surprised that the dict maintains insert order?
2025-07-15 13:08:32 -05:00
Mike Griese
e8c63a26e0 okay, fine 2025-07-15 10:17:49 -05:00
Mike Griese
6f867d178c spel 2025-07-15 10:09:57 -05:00
Mike Griese
4250bd82ac Merge branch 'dev/migrie/f/run-page-2-with-suggestions' into dev/migrie/f/run-page-2-with-history 2025-07-15 10:06:52 -05:00
Mike Griese
c4047a8ee2 Merge remote-tracking branch 'origin/main' into dev/migrie/f/run-page-2-with-suggestions 2025-07-15 10:05:08 -05:00
Jiří Polášek
cd00ecb791 Extend CommandItemViewModel to handle additional model changes affecting Title
CommandItemViewModel now updates its internal _itemTitle field not only when the CommandItem model raises a property notification for Title, but also when the model’s Command or the Command’s name changes.

This works around an issue in SDK versions before 0.3, where CommandItem does not raise property change notifications for Title in all effective cases.
This approach is not fully correct, as it relies on knowledge of CommandItem’s internal logic and the Title property being virtual and should be sooner or later reverted.
2025-07-10 00:00:16 +02:00
Jiří Polášek
abd0fb70f6 Raise Title property change notification when Command or Command.Name changes
Previously, the `Title` property raised a change notification only when set directly. However, the `Title` getter returns a coalesced value: if `Title` is not set, it falls back to `Command.Name`. This update ensures that property change notifications for `Title` are also raised when:
- The `Command` property changes
- The `Name` property of the assigned `Command` changes and affects the `Title`
2025-07-09 23:38:53 +02: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
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
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
63 changed files with 1268 additions and 559 deletions

View File

@@ -288,3 +288,6 @@ CACHEWRITE
MRUCMPPROC
MRUINFO
REGSTR
# Misc Win32 APIs and PInvokes
INVOKEIDLIST

View File

@@ -9,11 +9,11 @@ using Windows.AI.Actions.Hosting;
namespace Microsoft.CmdPal.Ext.Indexer.Commands;
internal sealed partial class ExecuteActionCommand : InvokableCommand
public sealed partial class ExecuteActionCommand : InvokableCommand
{
private readonly ActionInstance actionInstance;
internal ExecuteActionCommand(ActionInstance actionInstance)
public ExecuteActionCommand(ActionInstance actionInstance)
{
this.actionInstance = actionInstance;
this.Name = actionInstance.DisplayInfo.Description;

View File

@@ -6,28 +6,29 @@ using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CmdPal.Common.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Indexer.Commands;
namespace Microsoft.CmdPal.Common.Commands;
internal sealed partial class OpenInConsoleCommand : InvokableCommand
public partial class OpenInConsoleCommand : InvokableCommand
{
private readonly IndexerItem _item;
internal static IconInfo OpenInConsoleIcon { get; } = new("\uE756");
internal OpenInConsoleCommand(IndexerItem item)
private readonly string _path;
public OpenInConsoleCommand(string fullPath)
{
this._item = item;
this._path = fullPath;
this.Name = Resources.Indexer_Command_OpenPathInConsole;
this.Icon = new IconInfo("\uE756");
this.Icon = OpenInConsoleIcon;
}
public override CommandResult Invoke()
{
using (var process = new Process())
{
process.StartInfo.WorkingDirectory = Path.GetDirectoryName(_item.FullPath);
process.StartInfo.WorkingDirectory = Path.GetDirectoryName(_path);
process.StartInfo.FileName = "cmd.exe";
try
@@ -36,10 +37,10 @@ internal sealed partial class OpenInConsoleCommand : InvokableCommand
}
catch (Win32Exception ex)
{
Logger.LogError($"Unable to open {_item.FullPath}", ex);
Logger.LogError($"Unable to open '{_path}'", ex);
}
}
return CommandResult.GoHome();
return CommandResult.Dismiss();
}
}

View File

@@ -6,17 +6,17 @@ using System;
using System.Runtime.InteropServices;
using ManagedCommon;
using ManagedCsWin32;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CmdPal.Common.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Win32.UI.WindowsAndMessaging;
namespace Microsoft.CmdPal.Ext.Indexer.Commands;
namespace Microsoft.CmdPal.Common.Commands;
internal sealed partial class OpenPropertiesCommand : InvokableCommand
public partial class OpenPropertiesCommand : InvokableCommand
{
private readonly IndexerItem _item;
internal static IconInfo OpenPropertiesIcon { get; } = new("\uE90F");
private readonly string _path;
private static unsafe bool ShowFileProperties(string filename)
{
@@ -31,7 +31,7 @@ internal sealed partial class OpenPropertiesCommand : InvokableCommand
LpVerb = propertiesPtr,
LpFile = filenamePtr,
Show = (int)SHOW_WINDOW_CMD.SW_SHOW,
FMask = NativeHelpers.SEEMASKINVOKEIDLIST,
FMask = global::Windows.Win32.PInvoke.SEE_MASK_INVOKEIDLIST,
};
return Shell32.ShellExecuteEx(ref info);
@@ -43,24 +43,24 @@ internal sealed partial class OpenPropertiesCommand : InvokableCommand
}
}
internal OpenPropertiesCommand(IndexerItem item)
public OpenPropertiesCommand(string fullPath)
{
this._item = item;
this._path = fullPath;
this.Name = Resources.Indexer_Command_OpenProperties;
this.Icon = new IconInfo("\uE90F");
this.Icon = OpenPropertiesIcon;
}
public override CommandResult Invoke()
{
try
{
ShowFileProperties(_item.FullPath);
ShowFileProperties(_path);
}
catch (Exception ex)
{
Logger.LogError("Error showing file properties: ", ex);
}
return CommandResult.GoHome();
return CommandResult.Dismiss();
}
}

View File

@@ -4,17 +4,17 @@
using System.Runtime.InteropServices;
using ManagedCsWin32;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CmdPal.Common.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Win32.UI.WindowsAndMessaging;
namespace Microsoft.CmdPal.Ext.Indexer.Commands;
namespace Microsoft.CmdPal.Common.Commands;
internal sealed partial class OpenWithCommand : InvokableCommand
public partial class OpenWithCommand : InvokableCommand
{
private readonly IndexerItem _item;
internal static IconInfo OpenWithIcon { get; } = new("\uE7AC");
private readonly string _path;
private static unsafe bool OpenWith(string filename)
{
@@ -29,7 +29,7 @@ internal sealed partial class OpenWithCommand : InvokableCommand
LpVerb = verbPtr,
LpFile = filenamePtr,
Show = (int)SHOW_WINDOW_CMD.SW_SHOWNORMAL,
FMask = NativeHelpers.SEEMASKINVOKEIDLIST,
FMask = global::Windows.Win32.PInvoke.SEE_MASK_INVOKEIDLIST,
};
return Shell32.ShellExecuteEx(ref info);
@@ -41,16 +41,16 @@ internal sealed partial class OpenWithCommand : InvokableCommand
}
}
internal OpenWithCommand(IndexerItem item)
public OpenWithCommand(string fullPath)
{
this._item = item;
this._path = fullPath;
this.Name = Resources.Indexer_Command_OpenWith;
this.Icon = new IconInfo("\uE7AC");
this.Icon = OpenWithIcon;
}
public override CommandResult Invoke()
{
OpenWith(_item.FullPath);
OpenWith(_path);
return CommandResult.GoHome();
}

View File

@@ -0,0 +1,139 @@
// 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;
using System.Threading.Tasks;
namespace Microsoft.CmdPal.Common.Helpers;
/// <summary>
/// An async gate that ensures only one operation runs at a time.
/// If ExecuteAsync is called while already executing, it cancels the current execution
/// and starts the operation again (superseding behavior).
/// </summary>
public class SupersedingAsyncGate : IDisposable
{
private readonly Func<CancellationToken, Task> _action;
private readonly Lock _lock = new();
private int _callId;
private TaskCompletionSource<bool>? _currentTcs;
private CancellationTokenSource? _currentCancellationSource;
private Task? _executingTask;
public SupersedingAsyncGate(Func<CancellationToken, Task> action)
{
ArgumentNullException.ThrowIfNull(action);
_action = action;
}
/// <summary>
/// Executes the configured action. If another execution is running, this call will
/// cancel the current execution and restart the operation.
/// </summary>
/// <param name="cancellationToken">Optional external cancellation token</param>
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
TaskCompletionSource<bool> tcs;
lock (_lock)
{
_currentCancellationSource?.Cancel();
_currentTcs?.TrySetException(new OperationCanceledException("Superseded by newer call"));
tcs = new();
_currentTcs = tcs;
_callId++;
var shouldStartExecution = _executingTask is null;
if (shouldStartExecution)
{
_executingTask = Task.Run(ExecuteLoop, CancellationToken.None);
}
}
await using var ctr = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
await tcs.Task;
}
private async Task ExecuteLoop()
{
try
{
while (true)
{
TaskCompletionSource<bool>? currentTcs;
CancellationTokenSource? currentCts;
int currentCallId;
lock (_lock)
{
currentTcs = _currentTcs;
currentCallId = _callId;
if (currentTcs is null)
{
break;
}
_currentCancellationSource?.Dispose();
_currentCancellationSource = new();
currentCts = _currentCancellationSource;
}
try
{
await _action(currentCts.Token);
CompleteIfCurrent(currentTcs, currentCallId, static t => t.TrySetResult(true));
}
catch (OperationCanceledException)
{
CompleteIfCurrent(currentTcs, currentCallId, tcs => tcs.SetCanceled(currentCts.Token));
}
catch (Exception ex)
{
CompleteIfCurrent(currentTcs, currentCallId, tcs => tcs.TrySetException(ex));
}
}
}
finally
{
lock (_lock)
{
_currentTcs = null;
_currentCancellationSource?.Dispose();
_currentCancellationSource = null;
_executingTask = null;
}
}
}
private void CompleteIfCurrent(
TaskCompletionSource<bool> candidate,
int id,
Action<TaskCompletionSource<bool>> complete)
{
lock (_lock)
{
if (_currentTcs == candidate && _callId == id)
{
complete(candidate);
_currentTcs = null;
}
}
}
public void Dispose()
{
lock (_lock)
{
_currentCancellationSource?.Cancel();
_currentCancellationSource?.Dispose();
_currentTcs?.TrySetException(new ObjectDisposedException(nameof(SupersedingAsyncGate)));
_currentTcs = null;
}
GC.SuppressFinalize(this);
}
}

View File

@@ -28,7 +28,24 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" />
<ProjectReference Include="..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -9,3 +9,7 @@ GetWindowRect
GetMonitorInfo
SetWindowPos
MonitorFromWindow
SHOW_WINDOW_CMD
ShellExecuteEx
SEE_MASK_INVOKEIDLIST

View File

@@ -0,0 +1,99 @@
//------------------------------------------------------------------------------
// <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.Common.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()]
internal 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)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Common.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)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Open path in console.
/// </summary>
internal static string Indexer_Command_OpenPathInConsole {
get {
return ResourceManager.GetString("Indexer_Command_OpenPathInConsole", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Properties.
/// </summary>
internal static string Indexer_Command_OpenProperties {
get {
return ResourceManager.GetString("Indexer_Command_OpenProperties", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open with.
/// </summary>
internal static string Indexer_Command_OpenWith {
get {
return ResourceManager.GetString("Indexer_Command_OpenWith", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show in folder.
/// </summary>
internal static string Indexer_Command_ShowInFolder {
get {
return ResourceManager.GetString("Indexer_Command_ShowInFolder", resourceCulture);
}
}
}
}

View File

@@ -0,0 +1,132 @@
<?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="Indexer_Command_OpenPathInConsole" xml:space="preserve">
<value>Open path in console</value>
</data>
<data name="Indexer_Command_OpenProperties" xml:space="preserve">
<value>Properties</value>
</data>
<data name="Indexer_Command_OpenWith" xml:space="preserve">
<value>Open with</value>
</data>
<data name="Indexer_Command_ShowInFolder" xml:space="preserve">
<value>Show in folder</value>
</data>
</root>

View File

@@ -313,6 +313,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
Command = new(model.Command, PageContext);
Command.InitializeProperties();
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
_itemTitle = model.Title;
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Icon));
@@ -385,6 +389,14 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
switch (propertyName)
{
case nameof(Command.Name):
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
var model = _commandItemModel.Unsafe;
if (model != null)
{
_itemTitle = model.Title;
}
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Name));
break;

View File

@@ -14,8 +14,7 @@ using Windows.System;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ContextMenuViewModel : ObservableObject,
IRecipient<UpdateCommandBarMessage>,
IRecipient<OpenContextMenuMessage>
IRecipient<UpdateCommandBarMessage>
{
public ICommandBarContext? SelectedItem
{
@@ -43,7 +42,6 @@ public partial class ContextMenuViewModel : ObservableObject,
public ContextMenuViewModel()
{
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
WeakReferenceMessenger.Default.Register<OpenContextMenuMessage>(this);
}
public void Receive(UpdateCommandBarMessage message)
@@ -51,16 +49,6 @@ public partial class ContextMenuViewModel : ObservableObject,
SelectedItem = message.ViewModel;
}
public void Receive(OpenContextMenuMessage message)
{
FilterOnTop = message.ContextMenuFilterLocation == ContextMenuFilterLocation.Top;
ResetContextMenu();
OnPropertyChanging(nameof(FilterOnTop));
OnPropertyChanged(nameof(FilterOnTop));
}
public void UpdateContextItems()
{
if (SelectedItem != null)
@@ -192,7 +180,7 @@ public partial class ContextMenuViewModel : ObservableObject,
ListHelpers.InPlaceUpdateList(FilteredItems, [.. CurrentContextMenu!]);
}
private void ResetContextMenu()
public void ResetContextMenu()
{
while (ContextMenuStack.Count > 1)
{

View File

@@ -45,16 +45,4 @@
</Compile>
</ItemGroup>
<!-- Just mark it as AOT compatible. Do not publish with AOT now. We need fully test before we really publish it as AOT enabled-->
<!--<PropertyGroup>
<SelfContained>true</SelfContained>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<PublishTrimmed>true</PublishTrimmed>
<PublishSingleFile>true</PublishSingleFile>
--><!-- <DisableRuntimeMarshalling>true</DisableRuntimeMarshalling> --><!--
<PublishAot>true</PublishAot>
<EnableMsixTooling>true</EnableMsixTooling>
</PropertyGroup>-->
</Project>

View File

@@ -13,7 +13,8 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ShellViewModel : ObservableObject,
IRecipient<PerformCommandMessage>
IRecipient<PerformCommandMessage>,
IRecipient<HandleCommandResultMessage>
{
private readonly IRootPageService _rootPageService;
private readonly IAppHostService _appHostService;
@@ -77,6 +78,7 @@ public partial class ShellViewModel : ObservableObject,
// Register to receive messages
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
}
[RelayCommand]
@@ -358,6 +360,11 @@ public partial class ShellViewModel : ObservableObject,
WeakReferenceMessenger.Default.Send<GoBackMessage>(new(withAnimation, focusSearch));
}
public void Receive(HandleCommandResultMessage message)
{
UnsafeHandleCommandResult(message.Result.Unsafe);
}
private void OnUIThread(Action action)
{
_ = Task.Factory.StartNew(

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 ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CommandPalette.Extensions;
using Windows.ApplicationModel;
@@ -287,9 +288,17 @@ public partial class ExtensionService : IExtensionService, IDisposable
var installedExtensions = await GetInstalledExtensionsAsync();
foreach (var installedExtension in installedExtensions)
{
if (installedExtension.IsRunning())
Logger.LogDebug($"Signaling dispose to {installedExtension.ExtensionUniqueId}");
try
{
installedExtension.SignalDispose();
if (installedExtension.IsRunning())
{
installedExtension.SignalDispose();
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to send dispose signal to extension {installedExtension.ExtensionUniqueId}", ex);
}
}
}

View File

@@ -9,6 +9,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
@@ -20,7 +21,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>,
IPageContext
IPageContext,
IDisposable
{
private readonly IServiceProvider _serviceProvider;
private readonly TaskScheduler _taskScheduler;
@@ -28,6 +30,7 @@ public partial class TopLevelCommandManager : ObservableObject,
private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
private readonly Lock _commandProvidersLock = new();
private readonly SupersedingAsyncGate _reloadCommandsGate;
TaskScheduler IPageContext.Scheduler => _taskScheduler;
@@ -36,6 +39,7 @@ public partial class TopLevelCommandManager : ObservableObject,
_serviceProvider = serviceProvider;
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
}
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
@@ -144,46 +148,10 @@ public partial class TopLevelCommandManager : ObservableObject,
/// <returns>an awaitable task</returns>
private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, IItemsChangedEventArgs args)
{
// Work on a clone of the list, so that we can just do one atomic
// update to the actual observable list at the end
List<TopLevelViewModel> clone = [.. TopLevelCommands];
List<TopLevelViewModel> newItems = [];
var startIndex = -1;
var firstCommand = sender.TopLevelItems[0];
var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length;
// Tricky: all Commands from a single provider get added to the
// top-level list all together, in a row. So if we find just the first
// one, we can slice it out and insert the new ones there.
for (var i = 0; i < clone.Count; i++)
{
var wrapper = clone[i];
try
{
var isTheSame = wrapper == firstCommand;
if (isTheSame)
{
startIndex = i;
break;
}
}
catch
{
}
}
WeakReference<IPageContext> weakSelf = new(this);
// Fetch the new items
await sender.LoadTopLevelCommands(_serviceProvider, weakSelf);
var settings = _serviceProvider.GetService<SettingsModel>()!;
foreach (var i in sender.TopLevelItems)
{
newItems.Add(i);
}
List<TopLevelViewModel> newItems = [..sender.TopLevelItems];
foreach (var i in sender.FallbackItems)
{
if (i.IsEnabled)
@@ -192,25 +160,52 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
// Slice out the old commands
if (startIndex != -1)
// modify the TopLevelCommands under shared lock; event if we clone it, we don't want
// TopLevelCommands to get modified while we're working on it. Otherwise, we might
// out clone would be stale at the end of this method.
lock (TopLevelCommands)
{
clone.RemoveRange(startIndex, commandsToRemove);
}
else
{
// ... or, just stick them at the end (this is unexpected)
startIndex = clone.Count;
}
// Work on a clone of the list, so that we can just do one atomic
// update to the actual observable list at the end
// TODO: just added a lock around all of this anyway, but keeping the clone
// while looking on some other ways to improve this; can be removed later.
List<TopLevelViewModel> clone = [.. TopLevelCommands];
var startIndex = -1;
// add the new commands into the list at the place we found the old ones
clone.InsertRange(startIndex, newItems);
// Tricky: all Commands from a single provider get added to the
// top-level list all together, in a row. So if we find just the first
// one, we can slice it out and insert the new ones there.
for (var i = 0; i < clone.Count; i++)
{
var wrapper = clone[i];
try
{
if (sender.ProviderId == wrapper.CommandProviderId)
{
startIndex = i;
break;
}
}
catch
{
}
}
// now update the actual observable list with the new contents
ListHelpers.InPlaceUpdateList(TopLevelCommands, clone);
clone.RemoveAll(item => item.CommandProviderId == sender.ProviderId);
clone.InsertRange(startIndex, newItems);
ListHelpers.InPlaceUpdateList(TopLevelCommands, clone);
}
}
public async Task ReloadAllCommandsAsync()
{
// gate ensures that the reload is serialized and if multiple calls
// request a reload, only the first and the last one will be executed.
// this should be superseded with a cancellable version.
await _reloadCommandsGate.ExecuteAsync(CancellationToken.None);
}
private async Task ReloadAllCommandsAsyncCore(CancellationToken cancellationToken)
{
IsLoading = true;
var extensionService = _serviceProvider.GetService<IExtensionService>()!;
@@ -419,4 +414,10 @@ public partial class TopLevelCommandManager : ObservableObject,
|| _extensionCommandProviders.Any(wrapper => wrapper.Id == id && wrapper.IsActive);
}
}
public void Dispose()
{
_reloadCommandsGate.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -47,6 +47,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
public string CommandProviderId => _commandProviderId;
////// ICommandItem
public string Title => _commandItemViewModel.Title;
@@ -351,4 +353,9 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem
{
return new PerformCommandMessage(this.CommandViewModel.Model, new Core.ViewModels.Models.ExtensionObject<IListItem>(this));
}
public override string ToString()
{
return $"{nameof(TopLevelViewModel)}: {Id} ({Title}) - display: {DisplayTitle} - fallback: {IsFallback} - enabled: {IsEnabled}";
}
}

View File

@@ -76,44 +76,44 @@
<Grid
x:Name="IconRoot"
Margin="8,0,0,0"
Tapped="PageIcon_Tapped"
Margin="3,0,-5,0"
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay}">
<InfoBadge Visibility="{x:Bind CurrentPageViewModel.HasStatusMessage, Mode=OneWay}" Value="{x:Bind CurrentPageViewModel.StatusMessages.Count, Mode=OneWay}" />
<Grid.ContextFlyout>
<Flyout x:Name="StatusMessagesFlyout" Placement="TopEdgeAlignedLeft">
<ItemsRepeater
x:Name="MessagesDropdown"
Margin="-8"
ItemsSource="{x:Bind CurrentPageViewModel.StatusMessages, Mode=OneWay}"
Layout="{StaticResource VerticalStackLayout}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="coreViewModels:StatusMessageViewModel">
<StackPanel
Grid.Row="0"
Margin="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
CornerRadius="0">
<InfoBar
CornerRadius="{ThemeResource ControlCornerRadius}"
IsClosable="False"
IsOpen="True"
Message="{x:Bind Message, Mode=OneWay}"
Severity="{x:Bind State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}" />
</StackPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Flyout>
</Grid.ContextFlyout>
<Button
x:Name="StatusMessagesButton"
x:Uid="StatusMessagesButton"
Padding="4"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind CurrentPageViewModel.HasStatusMessage, Mode=OneWay}">
<InfoBadge Value="{x:Bind CurrentPageViewModel.StatusMessages.Count, Mode=OneWay}" />
<Button.Flyout>
<Flyout x:Name="StatusMessagesFlyout" Placement="TopEdgeAlignedLeft">
<ItemsRepeater
x:Name="MessagesDropdown"
Margin="-8"
ItemsSource="{x:Bind CurrentPageViewModel.StatusMessages, Mode=OneWay}"
Layout="{StaticResource VerticalStackLayout}">
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="coreViewModels:StatusMessageViewModel">
<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Bottom">
<InfoBar
CornerRadius="{ThemeResource ControlCornerRadius}"
IsClosable="False"
IsOpen="True"
Message="{x:Bind Message, Mode=OneWay}"
Severity="{x:Bind State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}" />
</StackPanel>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Flyout>
</Button.Flyout>
</Button>
</Grid>
<Button
x:Name="SettingsIconButton"
x:Uid="SettingsButton"
Click="SettingsIcon_Clicked"
Style="{StaticResource SubtleButtonStyle}"
Tapped="SettingsIcon_Tapped"
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon
@@ -146,8 +146,8 @@
x:Load="{x:Bind IsLoaded, Mode=OneWay}"
AutomationProperties.Name="{x:Bind ViewModel.PrimaryCommand.Name, Mode=OneWay}"
Background="Transparent"
Click="PrimaryButton_Clicked"
Style="{StaticResource SubtleButtonStyle}"
Tapped="PrimaryButton_Tapped"
Visibility="{x:Bind ViewModel.HasPrimaryCommand, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
@@ -169,8 +169,8 @@
Padding="6,4,4,4"
x:Load="{x:Bind IsLoaded, Mode=OneWay}"
AutomationProperties.Name="{x:Bind ViewModel.SecondaryCommand.Name, Mode=OneWay}"
Click="SecondaryButton_Clicked"
Style="{StaticResource SubtleButtonStyle}"
Tapped="SecondaryButton_Tapped"
Visibility="{x:Bind ViewModel.HasSecondaryCommand, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
@@ -200,8 +200,8 @@
x:Name="MoreCommandsButton"
x:Uid="MoreCommandsButton"
Padding="4"
Click="MoreCommandsButton_Clicked"
Style="{StaticResource SubtleButtonStyle}"
Tapped="MoreCommandsButton_Tapped"
ToolTipService.ToolTip="Ctrl+K"
Visibility="{x:Bind ViewModel.ShouldShowContextMenu, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">

View File

@@ -5,6 +5,7 @@
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -113,34 +114,23 @@ public sealed partial class CommandBar : UserControl,
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS has a tendency to delete XAML bound methods over-aggressively")]
private void PrimaryButton_Tapped(object sender, TappedRoutedEventArgs e)
private void PrimaryButton_Clicked(object sender, RoutedEventArgs e)
{
ViewModel.InvokePrimaryCommand();
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS has a tendency to delete XAML bound methods over-aggressively")]
private void SecondaryButton_Tapped(object sender, TappedRoutedEventArgs e)
private void SecondaryButton_Clicked(object sender, RoutedEventArgs e)
{
ViewModel.InvokeSecondaryCommand();
}
private void PageIcon_Tapped(object sender, TappedRoutedEventArgs e)
{
if (CurrentPageViewModel?.StatusMessages.Count > 0)
{
StatusMessagesFlyout.ShowAt(
placementTarget: IconRoot,
showOptions: new FlyoutShowOptions() { ShowMode = FlyoutShowMode.Standard });
}
}
private void SettingsIcon_Tapped(object sender, TappedRoutedEventArgs e)
private void SettingsIcon_Clicked(object sender, RoutedEventArgs e)
{
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
e.Handled = true;
}
private void MoreCommandsButton_Tapped(object sender, TappedRoutedEventArgs e)
private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e)
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));
}

View File

@@ -2,9 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.InteropServices;
using CommunityToolkit.WinUI;
using ManagedCommon;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -37,14 +35,7 @@ public partial class ContentIcon : FontIcon
{
if (this.FindDescendants().OfType<Grid>().FirstOrDefault() is Grid grid && Content is not null)
{
try
{
grid.Children.Add(Content);
}
catch (COMException ex)
{
Logger.LogError(ex.ToString());
}
grid.Children.Add(Content);
}
}
}

View File

@@ -131,7 +131,7 @@
ItemClick="CommandsDropdown_ItemClick"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
KeyDown="CommandsDropdown_KeyDown"
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">

View File

@@ -5,6 +5,7 @@
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -38,6 +39,9 @@ public sealed partial class ContextMenu : UserControl,
public void Receive(OpenContextMenuMessage message)
{
ViewModel.FilterOnTop = message.ContextMenuFilterLocation == ContextMenuFilterLocation.Top;
ViewModel.ResetContextMenu();
UpdateUiForStackChange();
}
@@ -80,7 +84,7 @@ public sealed partial class ContextMenu : UserControl,
}
}
private void CommandsDropdown_KeyDown(object sender, KeyRoutedEventArgs e)
private void CommandsDropdown_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Handled)
{
@@ -170,8 +174,6 @@ public sealed partial class ContextMenu : UserControl,
e.Handled = true;
}
CommandsDropdown_KeyDown(sender, e);
}
private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
@@ -188,6 +190,8 @@ public sealed partial class ContextMenu : UserControl,
e.Handled = true;
}
CommandsDropdown_PreviewKeyDown(sender, e);
}
private void NavigateUp()

View File

@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.Views;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
@@ -158,16 +159,6 @@ public sealed partial class SearchBar : UserControl,
CurrentPageViewModel.Filter = FilterBox.Text;
}
}
if (!e.Handled)
{
// The CommandBar is responsible for handling all the item keybindings,
// since the bound context item may need to then show another
// context menu
TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
}
}
private void FilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
@@ -255,6 +246,22 @@ public sealed partial class SearchBar : UserControl,
_inSuggestion = false;
_lastText = null;
}
if (!e.Handled)
{
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
// The CommandBar is responsible for handling all the item keybindings,
// since the bound context item may need to then show another
// context menu
TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
}
}
private void FilterBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e)

View File

@@ -113,13 +113,14 @@
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
ContextCanceled="ItemsList_OnContextCanceled"
ContextRequested="ItemsList_OnContextRequested"
DoubleTapped="ItemsList_DoubleTapped"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="ItemsList_ItemClick"
ItemTemplate="{StaticResource ListItemViewModelTemplate}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="ItemsList_RightTapped"
SelectionChanged="ItemsList_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />

View File

@@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
@@ -315,30 +316,51 @@ public sealed partial class ListPage : Page,
return null;
}
private void ItemsList_RightTapped(object sender, RightTappedRoutedEventArgs e)
private void ItemsList_OnContextRequested(UIElement sender, ContextRequestedEventArgs e)
{
if (e.OriginalSource is FrameworkElement element &&
element.DataContext is ListItemViewModel item)
var (item, element) = e.OriginalSource switch
{
if (ItemsList.SelectedItem != item)
{
ItemsList.SelectedItem = item;
}
// caused by keyboard shortcut (e.g. Context menu key or Shift+F10)
ListViewItem listViewItem => (ItemsList.ItemFromContainer(listViewItem) as ListItemViewModel, listViewItem),
ViewModel?.UpdateSelectedItemCommand.Execute(item);
// caused by right-click on the ListViewItem
FrameworkElement { DataContext: ListItemViewModel itemViewModel } frameworkElement => (itemViewModel, frameworkElement),
var pos = e.GetPosition(element);
_ => (null, null),
};
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
if (item == null || element == null)
{
return;
}
if (ItemsList.SelectedItem != item)
{
ItemsList.SelectedItem = item;
}
ViewModel?.UpdateSelectedItemCommand.Execute(item);
if (!e.TryGetPosition(element, out var pos))
{
pos = new(0, element.ActualHeight);
}
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
e.Handled = true;
}
private void ItemsList_OnContextCanceled(UIElement sender, RoutedEventArgs e)
{
_ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>());
}
}

View File

@@ -34,9 +34,9 @@ public sealed class IconCacheService(DispatcherQueue dispatcherQueue)
{
return await StreamToIconSource(icon.Data.Unsafe!);
}
catch
catch (Exception ex)
{
Debug.WriteLine("Failed to load icon from stream");
Debug.WriteLine("Failed to load icon from stream: " + ex);
}
}
}
@@ -63,17 +63,37 @@ public sealed class IconCacheService(DispatcherQueue dispatcherQueue)
{
// Return the bitmap image via TaskCompletionSource. Using WCT's EnqueueAsync does not suffice here, since if
// we're already on the thread of the DispatcherQueue then it just directly calls the function, with no async involved.
var completionSource = new TaskCompletionSource<BitmapImage>();
dispatcherQueue.TryEnqueue(async () =>
return await TryEnqueueAsync(dispatcherQueue, async () =>
{
using var bitmapStream = await iconStreamRef.OpenReadAsync();
var itemImage = new BitmapImage();
await itemImage.SetSourceAsync(bitmapStream);
completionSource.TrySetResult(itemImage);
return itemImage;
});
}
private static Task<T> TryEnqueueAsync<T>(DispatcherQueue dispatcher, Func<Task<T>> function)
{
var completionSource = new TaskCompletionSource<T>();
var enqueued = dispatcher.TryEnqueue(DispatcherQueuePriority.Normal, async void () =>
{
try
{
var result = await function();
completionSource.SetResult(result);
}
catch (Exception ex)
{
completionSource.SetException(ex);
}
});
var bitmapImage = await completionSource.Task;
if (!enqueued)
{
completionSource.SetException(new InvalidOperationException("Failed to enqueue the operation on the UI dispatcher"));
}
return bitmapImage;
return completionSource.Task;
}
}

View File

@@ -2,11 +2,12 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls.Primitives;
using Windows.Foundation;
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
namespace Microsoft.CmdPal.UI.Messages;
/// <summary>
/// Used to announce the context menu should open

View File

@@ -225,11 +225,11 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5"
Click="BackButton_Clicked"
Content="{ui:FontIcon Glyph=&#xE76B;,
FontSize=14}"
FontSize="16"
Style="{StaticResource SubtleButtonStyle}"
Tapped="BackButton_Tapped"
Visibility="{x:Bind ViewModel.CurrentPage.IsNested, Mode=OneWay}">
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation

View File

@@ -413,7 +413,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
private void BackButton_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
private void BackButton_Clicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
{

View File

@@ -428,4 +428,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_ExtensionPage_Alias_ToggleSwitch.OffContent" xml:space="preserve">
<value>Indirect</value>
</data>
<data name="StatusMessagesButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Show status messages</value>
</data>
<data name="StatusMessagesButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
<value>Show status messages</value>
</data>
</root>

View File

@@ -10,7 +10,6 @@ using System.Xml;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Apps.Commands;
using Microsoft.CmdPal.Ext.Apps.Properties;
using Microsoft.CmdPal.Ext.Apps.State;
using Microsoft.CmdPal.Ext.Apps.Utils;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -96,7 +95,7 @@ public class UWPApplication : IProgram
commands.Add(
new CommandContextItem(
new CopyPathCommand(Location))
new Commands.CopyPathCommand(Location))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C),
});

View File

@@ -5,21 +5,15 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.Design;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Reflection;
using System.Security;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows.Input;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Apps.Commands;
using Microsoft.CmdPal.Ext.Apps.Properties;
using Microsoft.CmdPal.Ext.Apps.State;
using Microsoft.CmdPal.Ext.Apps.Utils;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -190,7 +184,7 @@ public class Win32Program : IProgram
public List<IContextItem> GetCommands()
{
List<IContextItem> commands = new List<IContextItem>();
List<IContextItem> commands = [];
if (AppType != ApplicationType.InternetShortcutApplication && AppType != ApplicationType.Folder && AppType != ApplicationType.GenericFile)
{
@@ -208,7 +202,7 @@ public class Win32Program : IProgram
}
commands.Add(new CommandContextItem(
new CopyPathCommand(FullPath))
new Commands.CopyPathCommand(FullPath))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C),
});

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;
@@ -35,10 +34,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();
}
@@ -116,7 +112,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)));
@@ -124,10 +120,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 = Icons.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

@@ -1,34 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Indexer.Commands;
internal sealed partial class CopyPathCommand : InvokableCommand
{
private readonly IndexerItem _item;
internal CopyPathCommand(IndexerItem item)
{
this._item = item;
this.Name = Resources.Indexer_Command_CopyPath;
this.Icon = new IconInfo("\uE8c8");
}
public override CommandResult Invoke()
{
try
{
ClipboardHelper.SetText(_item.FullPath);
}
catch
{
}
return CommandResult.KeepOpen();
}
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
using System.Diagnostics;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Indexer.Commands;
internal sealed partial class OpenFileCommand : InvokableCommand
{
private readonly IndexerItem _item;
internal OpenFileCommand(IndexerItem item)
{
this._item = item;
this.Name = Resources.Indexer_Command_OpenFile;
this.Icon = Icons.OpenFileIcon;
}
public override CommandResult Invoke()
{
using (var process = new Process())
{
process.StartInfo.FileName = _item.FullPath;
process.StartInfo.UseShellExecute = true;
try
{
process.Start();
}
catch (Win32Exception ex)
{
Logger.LogError($"Unable to open {_item.FullPath}", ex);
}
}
return CommandResult.GoHome();
}
}

View File

@@ -12,6 +12,16 @@ internal sealed class IndexerItem
internal string FileName { get; init; }
internal IndexerItem()
{
}
internal IndexerItem(string fullPath)
{
FullPath = fullPath;
FileName = Path.GetFileName(fullPath);
}
internal bool IsDirectory()
{
if (!Path.Exists(FullPath))

View File

@@ -3,10 +3,11 @@
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Indexer.Commands;
using System.IO;
using System.Linq;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Ext.Indexer.Pages;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation.Metadata;
@@ -28,51 +29,79 @@ internal sealed partial class IndexerListItem : ListItem
public IndexerListItem(
IndexerItem indexerItem,
IncludeBrowseCommand browseByDefault = IncludeBrowseCommand.Include)
: base(new OpenFileCommand(indexerItem))
: base()
{
FilePath = indexerItem.FullPath;
Title = indexerItem.FileName;
Subtitle = indexerItem.FullPath;
List<CommandContextItem> context = [];
if (indexerItem.IsDirectory())
var commands = FileCommands(indexerItem.FullPath, browseByDefault);
if (commands.Any())
{
var directoryPage = new DirectoryPage(indexerItem.FullPath);
Command = commands.First().Command;
MoreCommands = commands.Skip(1).ToArray();
}
}
public static IEnumerable<CommandContextItem> FileCommands(string fullPath)
{
return FileCommands(fullPath, IncludeBrowseCommand.Include);
}
internal static IEnumerable<CommandContextItem> FileCommands(
string fullPath,
IncludeBrowseCommand browseByDefault = IncludeBrowseCommand.Include)
{
List<CommandContextItem> commands = [];
if (!Path.Exists(fullPath))
{
return commands;
}
// detect whether it is a directory or file
var attr = File.GetAttributes(fullPath);
var isDir = (attr & FileAttributes.Directory) == FileAttributes.Directory;
var openCommand = new OpenFileCommand(fullPath) { Name = Resources.Indexer_Command_OpenFile };
if (isDir)
{
var directoryPage = new DirectoryPage(fullPath);
if (browseByDefault == IncludeBrowseCommand.AsDefault)
{
// Swap the open file command into the context menu
context.Add(new CommandContextItem(Command));
Command = directoryPage;
// AsDefault: browse dir first, then open in explorer
commands.Add(new CommandContextItem(directoryPage));
commands.Add(new CommandContextItem(openCommand));
}
else if (browseByDefault == IncludeBrowseCommand.Include)
{
context.Add(new CommandContextItem(directoryPage));
// AsDefault: open in explorer first, then browse
commands.Add(new CommandContextItem(openCommand));
commands.Add(new CommandContextItem(directoryPage));
}
else if (browseByDefault == IncludeBrowseCommand.Exclude)
{
// AsDefault: Just open in explorer
commands.Add(new CommandContextItem(openCommand));
}
}
IContextItem[] moreCommands = [
..context,
new CommandContextItem(new OpenWithCommand(indexerItem))];
commands.Add(new CommandContextItem(new OpenWithCommand(fullPath)));
commands.Add(new CommandContextItem(new ShowFileInFolderCommand(fullPath) { Name = Resources.Indexer_Command_ShowInFolder }));
commands.Add(new CommandContextItem(new CopyPathCommand(fullPath) { Name = Resources.Indexer_Command_CopyPath }));
commands.Add(new CommandContextItem(new OpenInConsoleCommand(fullPath)));
commands.Add(new CommandContextItem(new OpenPropertiesCommand(fullPath)));
if (IsActionsFeatureEnabled && ApiInformation.IsApiContractPresent("Windows.AI.Actions.ActionsContract", 4))
{
var actionsListContextItem = new ActionsListContextItem(indexerItem.FullPath);
var actionsListContextItem = new ActionsListContextItem(fullPath);
if (actionsListContextItem.AnyActions())
{
moreCommands = [
.. moreCommands,
actionsListContextItem
];
commands.Add(actionsListContextItem);
}
}
MoreCommands = [
.. moreCommands,
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)),
];
return commands;
}
}

View File

@@ -60,7 +60,7 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System
if (Path.Exists(query))
{
// Exit 1: The query is a direct path to a file. Great! Return it.
var item = new IndexerItem() { FullPath = query, FileName = Path.GetFileName(query) };
var item = new IndexerItem(fullPath: query);
var listItemForUs = new IndexerListItem(item, IncludeBrowseCommand.AsDefault);
Command = listItemForUs.Command;
MoreCommands = listItemForUs.MoreCommands;

View File

@@ -10,14 +10,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" />
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Indexer.Commands;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -41,16 +41,16 @@ internal sealed partial class ExploreListItem : ListItem
}
else
{
Command = new OpenFileCommand(indexerItem);
Command = new OpenFileCommand(indexerItem.FullPath);
}
MoreCommands = [
..context,
new CommandContextItem(new OpenWithCommand(indexerItem)),
new CommandContextItem(new OpenWithCommand(indexerItem.FullPath)),
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)),
new CommandContextItem(new CopyPathCommand(indexerItem.FullPath)),
new CommandContextItem(new OpenInConsoleCommand(indexerItem.FullPath)),
new CommandContextItem(new OpenPropertiesCommand(indexerItem.FullPath)),
];
}
}

View File

@@ -89,7 +89,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
return;
}
ShellListPage.ParseExecutableAndArgs(searchText, out var exe, out var args);
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
// Check for cancellation before file system operations
cancellationToken.ThrowIfCancellationRequested();
@@ -191,7 +191,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
return false;
}
ShellListPage.ParseExecutableAndArgs(searchText, out var exe, out var args);
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
var exeExists = ShellListPageHelpers.FileExistInPath(exe, out var fullExePath);
var pathIsDir = Directory.Exists(exe);

View File

@@ -54,47 +54,8 @@ public class ShellListPageHelpers
internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
{
fullPath = string.Empty;
if (File.Exists(filename))
{
token?.ThrowIfCancellationRequested();
fullPath = Path.GetFullPath(filename);
return true;
}
else
{
var values = Environment.GetEnvironmentVariable("PATH");
if (values != null)
{
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;
}
}
// 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)
@@ -109,7 +70,7 @@ public class ShellListPageHelpers
return null;
}
ShellListPage.ParseExecutableAndArgs(searchText, out var exe, out var args);
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
var exeExists = false;
var pathIsDir = false;

View File

@@ -13,6 +13,7 @@
<None Remove="Assets\Run.svg" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
</ItemGroup>

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Shell.Pages;
@@ -54,13 +55,13 @@ internal sealed partial class RunExeItem : ListItem
{
Name = Properties.Resources.cmd_run_as_administrator,
Icon = Icons.AdminIcon,
}),
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) },
new CommandContextItem(
new AnonymousCommand(RunAsOther)
{
Name = Properties.Resources.cmd_run_as_user,
Icon = Icons.UserIcon,
}),
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) },
];
}

View File

@@ -26,7 +26,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
private readonly IRunHistoryService _historyService;
private RunExeItem? _exeItem;
private ListItem? _exeItem;
private List<ListItem> _pathItems = [];
private ListItem? _uriItem;
@@ -152,7 +152,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
return;
}
ParseExecutableAndArgs(expanded, out var exe, out var args);
ShellHelpers.ParseExecutableAndArgs(expanded, out var exe, out var args);
// Check for cancellation before file system operations
cancellationToken.ThrowIfCancellationRequested();
@@ -319,20 +319,19 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
.ToArray();
}
internal static RunExeItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
internal static ListItem 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);
return PathToListItem(fullExePath, exe, args, 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))
if (_exeItem is RunExeItem exeItem && exeItem.FullExePath.Equals(fullExePath, StringComparison.OrdinalIgnoreCase))
{
_exeItem.UpdateArgs(args);
exeItem.UpdateArgs(args);
}
else
{
@@ -345,7 +344,8 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
// 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));
var extension = Path.GetExtension(path);
return string.IsNullOrEmpty(extension) || extensions.Any(ext => string.Equals(extension, ext, StringComparison.OrdinalIgnoreCase));
}
private async Task CreatePathItemsAsync(string searchPath, string originalPath, CancellationToken cancellationToken)
@@ -439,46 +439,6 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
}
}
internal static void ParseExecutableAndArgs(string input, out string executable, out string arguments)
{
input = input.Trim();
executable = string.Empty;
arguments = string.Empty;
if (string.IsNullOrEmpty(input))
{
return;
}
if (input.StartsWith("\"", System.StringComparison.InvariantCultureIgnoreCase))
{
// Find the closing quote
var closingQuoteIndex = input.IndexOf('\"', 1);
if (closingQuoteIndex > 0)
{
executable = input.Substring(1, closingQuoteIndex - 1);
if (closingQuoteIndex + 1 < input.Length)
{
arguments = input.Substring(closingQuoteIndex + 1).TrimStart();
}
}
}
else
{
// Executable ends at first space
var firstSpaceIndex = input.IndexOf(' ');
if (firstSpaceIndex > 0)
{
executable = input.Substring(0, firstSpaceIndex);
arguments = input[(firstSpaceIndex + 1)..].TrimStart();
}
else
{
executable = input;
}
}
}
internal void CreateUriItems(string searchText)
{
if (!System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))

View File

@@ -4,8 +4,10 @@
using System;
using System.IO;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Shell;
@@ -46,18 +48,15 @@ internal sealed partial class PathListItem : ListItem
}
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)),
// ];
MoreCommands = [
new CommandContextItem(new OpenWithCommand(path)),
new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E) },
new CommandContextItem(new CopyPathCommand(path) { Name = Properties.Resources.copy_path_command_name }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) },
new CommandContextItem(new OpenInConsoleCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R) },
new CommandContextItem(new OpenPropertiesCommand(path)),
];
_icon = new Lazy<IconInfo>(() =>
{
var iconStream = ThumbnailHelper.GetThumbnail(path).Result;

View File

@@ -7,6 +7,8 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class CommandItem : BaseObservable, ICommandItem
{
private ICommand? _command;
private WeakEventListener<CommandItem, object, IPropChangedEventArgs>? _commandListener;
private string _title = string.Empty;
public virtual IIconInfo? Icon
{
@@ -20,17 +22,15 @@ public partial class CommandItem : BaseObservable, ICommandItem
public virtual string Title
{
get => !string.IsNullOrEmpty(field) ? field : _command?.Name ?? string.Empty;
get => !string.IsNullOrEmpty(_title) ? _title : _command?.Name ?? string.Empty;
set
{
field = value;
_title = value;
OnPropertyChanged(nameof(Title));
}
}
= string.Empty;
public virtual string Subtitle
{
get;
@@ -48,8 +48,33 @@ public partial class CommandItem : BaseObservable, ICommandItem
get => _command;
set
{
if (_commandListener != null)
{
_commandListener.Detach();
_commandListener = null;
}
_command = value;
if (value != null)
{
_commandListener = new(this, OnCommandPropertyChanged, listener => value.PropChanged -= listener.OnEvent);
value.PropChanged += _commandListener.OnEvent;
}
OnPropertyChanged(nameof(Command));
if (string.IsNullOrWhiteSpace(_title))
{
OnPropertyChanged(nameof(Title));
}
}
}
private static void OnCommandPropertyChanged(CommandItem instance, object source, IPropChangedEventArgs args)
{
if (args.PropertyName == nameof(ICommand.Name))
{
instance.OnPropertyChanged(nameof(Title));
}
}

View File

@@ -0,0 +1,34 @@
// 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.CommandPalette.Extensions.Toolkit;
public partial class CopyPathCommand : InvokableCommand
{
internal static IconInfo CopyPath { get; } = new("\uE8c8"); // Copy
private readonly string _path;
public CommandResult Result { get; set; } = CommandResult.KeepOpen();
public CopyPathCommand(string fullPath)
{
this._path = fullPath;
this.Name = "Copy path";
this.Icon = CopyPath;
}
public override CommandResult Invoke()
{
try
{
ClipboardHelper.SetText(_path);
}
catch
{
}
return Result;
}
}

View File

@@ -0,0 +1,44 @@
// 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.ComponentModel;
using System.Diagnostics;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class OpenFileCommand : InvokableCommand
{
internal static IconInfo OpenFile { get; } = new("\uE8E5"); // OpenFile
private readonly string _fullPath;
public CommandResult Result { get; set; } = CommandResult.Dismiss();
public OpenFileCommand(string fullPath)
{
this._fullPath = fullPath;
this.Name = "Open";
this.Icon = OpenFile;
}
public override CommandResult Invoke()
{
using (var process = new Process())
{
process.StartInfo.FileName = _fullPath;
process.StartInfo.UseShellExecute = true;
try
{
process.Start();
}
catch (Win32Exception ex)
{
ExtensionHost.LogMessage($"Unable to open {_fullPath}\n{ex}");
}
}
return Result;
}
}

View File

@@ -5,4 +5,4 @@ GetPackageFamilyNameFromToken
CoRevertToSelf
SHGetKnownFolderPath
KNOWN_FOLDER_FLAG
GetCurrentPackageId
GetCurrentPackageId

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;
}
}
}
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
/// <summary>
/// Implements a weak event listener that allows the owner to be garbage
/// collected if its only remaining link is an event handler.
/// </summary>
/// <typeparam name="TInstance">Type of instance listening for the event.</typeparam>
/// <typeparam name="TSource">Type of source for the event.</typeparam>
/// <typeparam name="TEventArgs">Type of event arguments for the event.</typeparam>
[EditorBrowsable(EditorBrowsableState.Never)]
internal sealed class WeakEventListener<TInstance, TSource, TEventArgs>
where TInstance : class
{
/// <summary>
/// WeakReference to the instance listening for the event.
/// </summary>
private readonly WeakReference<TInstance> _weakInstance;
/// <summary>
/// Initializes a new instance of the <see cref="WeakEventListener{TInstance, TSource, TEventArgs}"/> class.
/// </summary>
/// <param name="instance">Instance subscribing to the event.</param>
/// <param name="onEventAction">Event handler executed when event is raised.</param>
/// <param name="onDetachAction">Action to execute when instance was collected.</param>
public WeakEventListener(
TInstance instance,
Action<TInstance, TSource, TEventArgs>? onEventAction = null,
Action<WeakEventListener<TInstance, TSource, TEventArgs>>? onDetachAction = null)
{
ArgumentNullException.ThrowIfNull(instance);
_weakInstance = new(instance);
OnEventAction = onEventAction;
OnDetachAction = onDetachAction;
}
/// <summary>
/// Gets or sets the method to call when the event fires.
/// </summary>
public Action<TInstance, TSource, TEventArgs>? OnEventAction { get; set; }
/// <summary>
/// Gets or sets the method to call when detaching from the event.
/// </summary>
public Action<WeakEventListener<TInstance, TSource, TEventArgs>>? OnDetachAction { get; set; }
/// <summary>
/// Handler for the subscribed event calls OnEventAction to handle it.
/// </summary>
/// <param name="source">Event source.</param>
/// <param name="eventArgs">Event arguments.</param>
public void OnEvent(TSource source, TEventArgs eventArgs)
{
if (_weakInstance.TryGetTarget(out var target))
{
// Call registered action
OnEventAction?.Invoke(target, source, eventArgs);
}
else
{
// Detach from event
Detach();
}
}
/// <summary>
/// Detaches from the subscribed event.
/// </summary>
public void Detach()
{
OnDetachAction?.Invoke(this);
OnDetachAction = null;
}
}