diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 3f53a4db6d..4d3980e8c5 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -366,6 +366,7 @@ desktopshorcutinstalled DESKTOPVERTRES devblogs devdocs +devenv devmgmt DEVMODE DEVMODEW @@ -822,6 +823,7 @@ killrunner kmph kvp Kybd +LARGEICON lastcodeanalysissucceeded LASTEXITCODE LAYOUTRTL @@ -1200,8 +1202,10 @@ PACL PAINTSTRUCT PALETTEWINDOW PARENTNOTIFY +PARENTRELATIVE PARENTRELATIVEEDITING PARENTRELATIVEFORADDRESSBAR +PARENTRELATIVEFORUI PARENTRELATIVEPARSING parray PARTIALCONFIRMATIONDIALOGTITLE @@ -1257,6 +1261,7 @@ pgp pguid phbm phbmp +phicon phwnd pici pidl @@ -1265,6 +1270,7 @@ pinfo pinvoke pipename PKBDLLHOOKSTRUCT +pkgfamily plib ploc ploca diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs index 5181165d7f..f5e4d3e97b 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs @@ -13,7 +13,7 @@ namespace Microsoft.CmdPal.Core.Common.Helpers; /// If ExecuteAsync is called while already executing, it cancels the current execution /// and starts the operation again (superseding behavior). /// -public partial class SupersedingAsyncGate : IDisposable +public sealed partial class SupersedingAsyncGate : IDisposable { private readonly Func _action; private readonly Lock _lock = new(); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs new file mode 100644 index 0000000000..4fab6bf194 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// +/// An async gate that ensures only one value computation runs at a time. +/// If ExecuteAsync is called while already executing, it cancels the current computation +/// and starts the operation again (superseding behavior). +/// Once a value is successfully computed, it is applied (via the provided ). +/// The apply step uses its own lock so that long-running apply logic does not block the +/// computation / superseding pipeline, while still remaining serialized with respect to +/// other apply calls. +/// +/// The type of the computed value. +public sealed partial class SupersedingAsyncValueGate : IDisposable +{ + private readonly Func> _valueFactory; + private readonly Action _apply; + private readonly Lock _lock = new(); // Controls scheduling / superseding + private readonly Lock _applyLock = new(); // Serializes application of results + private int _callId; + private TaskCompletionSource? _currentTcs; + private CancellationTokenSource? _currentCancellationSource; + private Task? _executingTask; + + public SupersedingAsyncValueGate( + Func> valueFactory, + Action apply) + { + ArgumentNullException.ThrowIfNull(valueFactory); + ArgumentNullException.ThrowIfNull(apply); + _valueFactory = valueFactory; + _apply = apply; + } + + /// + /// Executes the configured value computation. If another execution is running, this call will + /// cancel the current execution and restart the computation. The returned task completes when + /// (and only if) the computation associated with this invocation completes (or is canceled / superseded). + /// + /// Optional external cancellation token. + /// The computed value for this invocation. + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource tcs; + + lock (_lock) + { + // Supersede any in-flight computation. + _currentCancellationSource?.Cancel(); + _currentTcs?.TrySetException(new OperationCanceledException("Superseded by newer call")); + + tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + _currentTcs = tcs; + _callId++; + + if (_executingTask is null) + { + _executingTask = Task.Run(ExecuteLoop, CancellationToken.None); + } + } + + using var ctr = cancellationToken.Register(state => ((TaskCompletionSource)state!).TrySetCanceled(cancellationToken), tcs); + return await tcs.Task.ConfigureAwait(false); + } + + private async Task ExecuteLoop() + { + try + { + while (true) + { + TaskCompletionSource? currentTcs; + CancellationTokenSource? currentCts; + int currentCallId; + + lock (_lock) + { + currentTcs = _currentTcs; + currentCallId = _callId; + + if (currentTcs is null) + { + break; // Nothing pending. + } + + _currentCancellationSource?.Dispose(); + _currentCancellationSource = new(); + currentCts = _currentCancellationSource; + } + + try + { + var value = await _valueFactory(currentCts.Token).ConfigureAwait(false); + CompleteSuccessIfCurrent(currentTcs, currentCallId, value); + } + catch (OperationCanceledException) + { + CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetCanceled(currentCts.Token)); + } + catch (Exception ex) + { + CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetException(ex)); + } + } + } + finally + { + lock (_lock) + { + _currentTcs = null; + _currentCancellationSource?.Dispose(); + _currentCancellationSource = null; + _executingTask = null; + } + } + } + + private void CompleteSuccessIfCurrent(TaskCompletionSource candidate, int id, T value) + { + var shouldApply = false; + lock (_lock) + { + if (_currentTcs == candidate && _callId == id) + { + // Mark as consumed so a new computation can start immediately. + _currentTcs = null; + shouldApply = true; + } + } + + if (!shouldApply) + { + return; // Superseded meanwhile. + } + + Exception? applyException = null; + try + { + lock (_applyLock) + { + _apply(value); + } + } + catch (Exception ex) + { + applyException = ex; + } + + if (applyException is null) + { + candidate.TrySetResult(value); + } + else + { + candidate.TrySetException(applyException); + } + } + + private void CompleteIfCurrent( + TaskCompletionSource candidate, + int id, + Action> 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(SupersedingAsyncValueGate))); + _currentTcs = null; + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index d622d7dcb6..6bf9ec7c86 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -114,7 +114,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(files); - services.AddSingleton(); + services.AddSingleton(_ => BookmarksCommandProvider.CreateWithDefaultStore()); services.AddSingleton(); services.AddSingleton(); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs deleted file mode 100644 index 2ee3deeb5d..0000000000 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs +++ /dev/null @@ -1,42 +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.VisualStudio.TestTools.UnitTesting; - -namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; - -[TestClass] -public class BookmarkDataTests -{ - [TestMethod] - public void BookmarkDataWebUrlDetection() - { - // Act - var webBookmark = new BookmarkData - { - Name = "Test Site", - Bookmark = "https://test.com", - }; - - var nonWebBookmark = new BookmarkData - { - Name = "Local File", - Bookmark = "C:\\temp\\file.txt", - }; - - var placeholderBookmark = new BookmarkData - { - Name = "Placeholder", - Bookmark = "{Placeholder}", - }; - - // Assert - Assert.IsTrue(webBookmark.IsWebUrl()); - Assert.IsFalse(webBookmark.IsPlaceholder); - Assert.IsFalse(nonWebBookmark.IsWebUrl()); - Assert.IsFalse(nonWebBookmark.IsPlaceholder); - - Assert.IsTrue(placeholderBookmark.IsPlaceholder); - } -} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs index e442818f8a..a813ac4464 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; @@ -191,7 +193,7 @@ public class BookmarkJsonParserTests public void SerializeBookmarks_ValidBookmarks_ReturnsJsonString() { // Arrange - var bookmarks = new Bookmarks + var bookmarks = new BookmarksData { Data = new List { @@ -216,7 +218,7 @@ public class BookmarkJsonParserTests public void SerializeBookmarks_EmptyBookmarks_ReturnsValidJson() { // Arrange - var bookmarks = new Bookmarks(); + var bookmarks = new BookmarksData(); // Act var result = _parser.SerializeBookmarks(bookmarks); @@ -241,7 +243,7 @@ public class BookmarkJsonParserTests public void ParseBookmarks_RoundTripSerialization_PreservesData() { // Arrange - var originalBookmarks = new Bookmarks + var originalBookmarks = new BookmarksData { Data = new List { @@ -263,7 +265,6 @@ public class BookmarkJsonParserTests { Assert.AreEqual(originalBookmarks.Data[i].Name, parsedBookmarks.Data[i].Name); Assert.AreEqual(originalBookmarks.Data[i].Bookmark, parsedBookmarks.Data[i].Bookmark); - Assert.AreEqual(originalBookmarks.Data[i].IsPlaceholder, parsedBookmarks.Data[i].IsPlaceholder); } } @@ -296,70 +297,6 @@ public class BookmarkJsonParserTests // Assert Assert.IsNotNull(result); Assert.AreEqual(3, result.Data.Count); - - Assert.IsFalse(result.Data[0].IsPlaceholder); - Assert.IsTrue(result.Data[1].IsPlaceholder); - Assert.IsTrue(result.Data[2].IsPlaceholder); - } - - [TestMethod] - public void ParseBookmarks_IsWebUrl_CorrectlyIdentifiesWebUrls() - { - // Arrange - var json = """ - { - "Data": [ - { - "Name": "HTTPS Website", - "Bookmark": "https://www.google.com" - }, - { - "Name": "HTTP Website", - "Bookmark": "http://example.com" - }, - { - "Name": "Website without protocol", - "Bookmark": "www.github.com" - }, - { - "Name": "Local File Path", - "Bookmark": "C:\\Users\\test\\Documents\\file.txt" - }, - { - "Name": "Network Path", - "Bookmark": "\\\\server\\share\\file.txt" - }, - { - "Name": "Executable", - "Bookmark": "notepad.exe" - }, - { - "Name": "File URI", - "Bookmark": "file:///C:/temp/file.txt" - } - ] - } - """; - - // Act - var result = _parser.ParseBookmarks(json); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(7, result.Data.Count); - - // Web URLs should return true - Assert.IsTrue(result.Data[0].IsWebUrl(), "HTTPS URL should be identified as web URL"); - Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTP URL should be identified as web URL"); - - // This case will fail. We need to consider if we need to support pure domain value in bookmark. - // Assert.IsTrue(result.Data[2].IsWebUrl(), "Domain without protocol should be identified as web URL"); - - // Non-web URLs should return false - Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file path should not be identified as web URL"); - Assert.IsFalse(result.Data[4].IsWebUrl(), "Network path should not be identified as web URL"); - Assert.IsFalse(result.Data[5].IsWebUrl(), "Executable should not be identified as web URL"); - Assert.IsFalse(result.Data[6].IsWebUrl(), "File URI should not be identified as web URL"); } [TestMethod] @@ -415,23 +352,10 @@ public class BookmarkJsonParserTests // Assert Assert.IsNotNull(result); Assert.AreEqual(9, result.Data.Count); - - // Should be identified as placeholders - Assert.IsTrue(result.Data[0].IsPlaceholder, "Simple placeholder should be identified"); - Assert.IsTrue(result.Data[1].IsPlaceholder, "Multiple placeholders should be identified"); - Assert.IsTrue(result.Data[2].IsPlaceholder, "Web URL with placeholder should be identified"); - Assert.IsTrue(result.Data[3].IsPlaceholder, "Complex placeholder should be identified"); - Assert.IsTrue(result.Data[8].IsPlaceholder, "Empty placeholder should be identified"); - - // Should NOT be identified as placeholders - Assert.IsFalse(result.Data[4].IsPlaceholder, "Regular URL should not be placeholder"); - Assert.IsFalse(result.Data[5].IsPlaceholder, "Local file should not be placeholder"); - Assert.IsFalse(result.Data[6].IsPlaceholder, "Only opening brace should not be placeholder"); - Assert.IsFalse(result.Data[7].IsPlaceholder, "Only closing brace should not be placeholder"); } [TestMethod] - public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesBothWebUrlAndPlaceholder() + public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesPlaceholder() { // Arrange var json = """ @@ -463,73 +387,5 @@ public class BookmarkJsonParserTests // Assert Assert.IsNotNull(result); Assert.AreEqual(4, result.Data.Count); - - // Web URL with placeholder - Assert.IsTrue(result.Data[0].IsWebUrl(), "Web URL with placeholder should be identified as web URL"); - Assert.IsTrue(result.Data[0].IsPlaceholder, "Web URL with placeholder should be identified as placeholder"); - - // Web URL without placeholder - Assert.IsTrue(result.Data[1].IsWebUrl(), "Web URL without placeholder should be identified as web URL"); - Assert.IsFalse(result.Data[1].IsPlaceholder, "Web URL without placeholder should not be identified as placeholder"); - - // Local file with placeholder - Assert.IsFalse(result.Data[2].IsWebUrl(), "Local file with placeholder should not be identified as web URL"); - Assert.IsTrue(result.Data[2].IsPlaceholder, "Local file with placeholder should be identified as placeholder"); - - // Local file without placeholder - Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file without placeholder should not be identified as web URL"); - Assert.IsFalse(result.Data[3].IsPlaceholder, "Local file without placeholder should not be identified as placeholder"); - } - - [TestMethod] - public void ParseBookmarks_EdgeCaseUrls_CorrectlyIdentifiesWebUrls() - { - // Arrange - var json = """ - { - "Data": [ - { - "Name": "FTP URL", - "Bookmark": "ftp://files.example.com" - }, - { - "Name": "HTTPS with port", - "Bookmark": "https://localhost:8080" - }, - { - "Name": "IP Address", - "Bookmark": "http://192.168.1.1" - }, - { - "Name": "Subdomain", - "Bookmark": "https://api.github.com" - }, - { - "Name": "Domain only", - "Bookmark": "example.com" - }, - { - "Name": "Not a URL - no dots", - "Bookmark": "localhost" - } - ] - } - """; - - // Act - var result = _parser.ParseBookmarks(json); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(6, result.Data.Count); - - Assert.IsFalse(result.Data[0].IsWebUrl(), "FTP URL should not be identified as web URL"); - Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTPS with port should be identified as web URL"); - Assert.IsTrue(result.Data[2].IsWebUrl(), "IP Address with HTTP should be identified as web URL"); - Assert.IsTrue(result.Data[3].IsWebUrl(), "Subdomain should be identified as web URL"); - - // This case will fail. We need to consider if we need to support pure domain value in bookmark. - // Assert.IsTrue(result.Data[4].IsWebUrl(), "Domain only should be identified as web URL"); - Assert.IsFalse(result.Data[5].IsWebUrl(), "Single word without dots should not be identified as web URL"); } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs new file mode 100644 index 0000000000..0751b5afe3 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs @@ -0,0 +1,189 @@ +// 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.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarkManagerTests +{ + [TestMethod] + public void BookmarkManager_CanBeInstantiated() + { + // Arrange & Act + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + + // Assert + Assert.IsNotNull(bookmarkManager); + } + + [TestMethod] + public void BookmarkManager_InitialBookmarksEmpty() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + + // Act + var bookmarks = bookmarkManager.Bookmarks; + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(0, bookmarks.Count); + } + + [TestMethod] + public void BookmarkManager_InitialBookmarksCorruptedData() + { + // Arrange + var json = "@*>$ß Corrupted data. Hey, this is not JSON!"; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks; + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(0, bookmarks.Count); + } + + [TestMethod] + public void BookmarkManager_InitializeWithExistingData() + { + // Arrange + const string json = """ + { + "Data":[ + {"Id":"d290f1ee-6c54-4b01-90e6-d701748f0851","Name":"Bookmark1","Bookmark":"C:\\Path1"}, + {"Id":"c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a","Name":"Bookmark2","Bookmark":"D:\\Path2"} + ] + } + """; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks?.ToList(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(2, bookmarks.Count); + + Assert.AreEqual("Bookmark1", bookmarks[0].Name); + Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark); + Assert.AreEqual(Guid.Parse("d290f1ee-6c54-4b01-90e6-d701748f0851"), bookmarks[0].Id); + + Assert.AreEqual("Bookmark2", bookmarks[1].Name); + Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark); + Assert.AreEqual(Guid.Parse("c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a"), bookmarks[1].Id); + } + + [TestMethod] + public void BookmarkManager_InitializeWithLegacyData_GeneratesIds() + { + // Arrange + const string json = """ + { + "Data": + [ + { "Name":"Bookmark1", "Bookmark":"C:\\Path1" }, + { "Name":"Bookmark2", "Bookmark":"D:\\Path2" } + ] + } + """; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks?.ToList(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(2, bookmarks.Count); + + Assert.AreEqual("Bookmark1", bookmarks[0].Name); + Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark); + Assert.AreNotEqual(Guid.Empty, bookmarks[0].Id); + + Assert.AreEqual("Bookmark2", bookmarks[1].Name); + Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark); + Assert.AreNotEqual(Guid.Empty, bookmarks[1].Id); + + Assert.AreNotEqual(bookmarks[0].Id, bookmarks[1].Id); + } + + [TestMethod] + public void BookmarkManager_AddBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var bookmarkAddedEventFired = false; + bookmarkManager.BookmarkAdded += (bookmark) => + { + bookmarkAddedEventFired = true; + Assert.AreEqual("TestBookmark", bookmark.Name); + Assert.AreEqual("C:\\TestPath", bookmark.Bookmark); + }; + + // Act + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.AreEqual(1, bookmarks.Count); + Assert.AreEqual(addedBookmark, bookmarks.First()); + Assert.IsTrue(bookmarkAddedEventFired); + } + + [TestMethod] + public void BookmarkManager_RemoveBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + var bookmarkRemovedEventFired = false; + bookmarkManager.BookmarkRemoved += (bookmark) => + { + bookmarkRemovedEventFired = true; + Assert.AreEqual(addedBookmark, bookmark); + }; + + // Act + var removeResult = bookmarkManager.Remove(addedBookmark.Id); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.IsTrue(removeResult); + Assert.AreEqual(0, bookmarks.Count); + Assert.IsTrue(bookmarkRemovedEventFired); + } + + [TestMethod] + public void BookmarkManager_UpdateBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + var bookmarkUpdatedEventFired = false; + bookmarkManager.BookmarkUpdated += (data, bookmarkData) => + { + bookmarkUpdatedEventFired = true; + Assert.AreEqual(addedBookmark, data); + Assert.AreEqual("UpdatedBookmark", bookmarkData.Name); + Assert.AreEqual("D:\\UpdatedPath", bookmarkData.Bookmark); + }; + + // Act + var updatedBookmark = bookmarkManager.Update(addedBookmark.Id, "UpdatedBookmark", "D:\\UpdatedPath"); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.IsNotNull(updatedBookmark); + Assert.AreEqual(1, bookmarks.Count); + Assert.AreEqual(updatedBookmark, bookmarks.First()); + Assert.AreEqual("UpdatedBookmark", updatedBookmark.Name); + Assert.AreEqual("D:\\UpdatedPath", updatedBookmark.Bookmark); + Assert.IsTrue(bookmarkUpdatedEventFired); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs new file mode 100644 index 0000000000..2fa7b81b08 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs @@ -0,0 +1,303 @@ +// 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.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(nameof(CommonClassificationData.CommonCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateCommonClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(nameof(CommonClassificationData.UwpAumidCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUwpAumidClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedRelativePaths), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUnquotedRelativePathScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedShellProtocol), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUnquotedShellProtocolScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + private static class CommonClassificationData + { + public static IEnumerable CommonCases() + { + return + [ + [ + new PlaceholderClassificationCase( + Name: "HTTPS URL", + Input: "https://microsoft.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://microsoft.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "WWW URL without scheme", + Input: "www.example.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://www.example.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "HTTP URL with query", + Input: "http://yahoo.com?p=search", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "http://yahoo.com?p=search", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Mailto protocol", + Input: "mailto:user@example.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "mailto:user@example.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "MS-Settings protocol", + Input: "ms-settings:display", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "ms-settings:display", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Custom protocol", + Input: "myapp:doit", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "myapp:doit", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Not really a valid protocol", + Input: "this is not really a protocol myapp: doit", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "this", + ExpectedArguments: "is not really a protocol myapp: doit", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Drive", + Input: "C:", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: "C:\\", + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Non-existing path with extension", + Input: "C:\\this-folder-should-not-exist-12345\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\this-folder-should-not-exist-12345\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Unknown fallback", + Input: "some_unlikely_command_name_12345", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "some_unlikely_command_name_12345", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + + [new PlaceholderClassificationCase( + Name: "Simple unquoted executable path", + Input: "C:\\Windows\\System32\\notepad.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Windows\\System32\\notepad.exe", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Unquoted document path (non existed file)", + Input: "C:\\Users\\John\\Documents\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Users\\John\\Documents\\file.txt", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ] + ]; + } + + public static IEnumerable UwpAumidCases() => + [ + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with AppsFolder prefix", + Input: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with AppsFolder prefix and argument (Trap)", + Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID via AppsFolder", + Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + ]; + + public static IEnumerable UnquotedShellProtocol() => + [ + [ + new PlaceholderClassificationCase( + Name: "Shell for This PC (shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D})", + Input: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell for This PC (::{20D04FE0-3AEA-1069-A2D8-08002B30309D})", + Input: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for My Documents (shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + Input: "shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for My Documents (::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + Input: "::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for AppData (shell:appdata)", + Input: "shell:appdata", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + + // let's pray this works on all systems + new PlaceholderClassificationCase( + Name: "Shell protocol for AppData + subpath (shell:appdata\\microsoft)", + Input: "shell:appdata\\microsoft", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft"), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + ]; + + public static IEnumerable UnquotedRelativePaths() => + [ + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative current path", + Input: ".\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], +#if CMDPAL_ENABLE_UNSAFE_TESTS + It's not really a good idea blindly write to directory out of user profile + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative parent path", + Input: "..\\parent folder\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], +#endif // CMDPAL_ENABLE_UNSAFE_TESTS + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative home folder", + Input: $"~\\{_testDirName}\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(_testDirPath, "app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs new file mode 100644 index 0000000000..c4c455d5a9 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs @@ -0,0 +1,369 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(nameof(PlaceholderClassificationData.PlaceholderCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Placeholders_ValidatePlaceholderClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(nameof(PlaceholderClassificationData.EdgeCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Placeholders_ValidatePlaceholderEdgeCases(PlaceholderClassificationCase c) + { + // Arrange + IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser()); + + // Act & Assert - Should not throw exceptions + var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None); + + Assert.IsNotNull(classification); + Assert.AreEqual(c.ExpectSuccess, classification.Success); + + if (c.ExpectSuccess && classification.Result != null) + { + Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder); + Assert.AreEqual(c.Input, classification.Result.Input, "OriginalInput should be preserved"); + } + } + + private static class PlaceholderClassificationData + { + public static IEnumerable PlaceholderCases() + { + // UWP/AUMID with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with package placeholder", + Input: "shell:AppsFolder\\{packageFamily}!{appId}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\{packageFamily}!{appId}", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + + // Expects no special handling + new PlaceholderClassificationCase( + Name: "Bare UWP AUMID with placeholders", + Input: "{packageFamily}!{appId}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{packageFamily}!{appId}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Web URLs with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "HTTPS URL with domain placeholder", + Input: "https://{domain}/path", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://{domain}/path", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "WWW URL with site placeholder", + Input: "www.{site}.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://www.{site}.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "WWW URL - Yahoo with Search", + Input: "http://yahoo.com?p={search}", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "http://yahoo.com?p={search}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Protocol URLs with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Mailto protocol with email placeholder", + Input: "mailto:{email}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "mailto:{email}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "MS-Settings protocol with category placeholder", + Input: "ms-settings:{category}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "ms-settings:{category}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // File executables with placeholders - These might classify as Unknown currently + // due to nonexistent paths, but should preserve placeholder flag + yield return + [ + new PlaceholderClassificationCase( + Name: "Executable with profile path placeholder", + Input: "{userProfile}\\Documents\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist + ExpectedTarget: "{userProfile}\\Documents\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Executable with program files placeholder", + Input: "{programFiles}\\MyApp\\tool.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist + ExpectedTarget: "{programFiles}\\MyApp\\tool.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Commands with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Command with placeholder and arguments", + Input: "{editor} {filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, // Likely Unknown since command won't be found in PATH + ExpectedTarget: "{editor}", + ExpectedArguments: "{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Directory paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Directory with user profile placeholder", + Input: "{userProfile}\\Documents", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, // May be Unknown if path doesn't exist during classification + ExpectedTarget: "{userProfile}\\Documents", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Complex quoted paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Quoted executable path with placeholders and args", + Input: "\"{programFiles}\\{appName}\\{executable}.exe\" --verbose", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // Likely Unknown due to nonexistent path + ExpectedTarget: "{programFiles}\\{appName}\\{executable}.exe", + ExpectedArguments: "--verbose", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Shell paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Shell folder with placeholder", + Input: "shell:{folder}\\{filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:{folder}\\{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Shell paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Shell folder with placeholder", + Input: "shell:knownFolder\\{filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:knownFolder\\{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + yield return + [ + + // cmd /K {param1} + new PlaceholderClassificationCase( + Name: "Command with braces in arguments", + Input: "cmd /K {param1}", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedArguments: "/K {param1}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Mixed literal and placeholder paths + yield return + [ + new PlaceholderClassificationCase( + Name: "Mixed literal and placeholder path", + Input: "C:\\{folder}\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // Behavior depends on partial path resolution + ExpectedTarget: "C:\\{folder}\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Multiple placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Multiple placeholders in path", + Input: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + } + + public static IEnumerable EdgeCases() + { + // Empty and malformed placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Empty placeholder", + Input: "{} file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{} file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Unclosed placeholder", + Input: "{unclosed file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{unclosed file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with spaces", + Input: "{with spaces}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{with spaces}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Nested placeholders", + Input: "{outer{inner}}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{outer{inner}}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Only closing brace", + Input: "file} something", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "file} something", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + // Very long placeholder names + yield return + [ + new PlaceholderClassificationCase( + Name: "Very long placeholder name", + Input: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Special characters in placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with underscores", + Input: "{user_profile}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{user_profile}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with numbers", + Input: "{path123}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{path123}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs new file mode 100644 index 0000000000..ceda208996 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs @@ -0,0 +1,669 @@ +// 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.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.MixedQuotesScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateMixedQuotesScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EscapedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEscapedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.PartialMalformedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidatePartialMalformedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EnvironmentVariablesWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEnvironmentVariablesWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.ShellProtocolPathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateShellProtocolPathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.CommandFlagsAndOptions), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateCommandFlagsAndOptions(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.NetworkPathsUnc), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateNetworkPathsUnc(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RelativePathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateRelativePathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EmptyAndWhitespaceCases), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEmptyAndWhitespaceCases(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RealWorldCommandScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateRealWorldCommandScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.SpecialCharactersInPaths), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateSpecialCharactersInPaths(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsCurrentlyBroken), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedPathsCurrentlyBroken(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsInCommands), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedPathsInCommands(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedAumid), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedUwpAppAumidCommands(PlaceholderClassificationCase c) => await RunShared(c: c); + + public static class QuotedClassificationData + { + public static IEnumerable MixedQuotesScenarios() => + [ + [ + new PlaceholderClassificationCase( + Name: "Executable with quoted argument", + Input: "C:\\Windows\\notepad.exe \"C:\\my file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Windows\\notepad.exe", + ExpectedArguments: "\"C:\\my file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "App with quoted argument containing spaces", + Input: "app.exe \"argument with spaces\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "app.exe", + ExpectedArguments: "\"argument with spaces\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Tool with input flag and quoted file", + Input: "C:\\tool.exe -input \"data file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\tool.exe", + ExpectedArguments: "-input \"data file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Multiple quoted arguments after path", + Input: "\"C:\\Program Files\\app.exe\" -file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedArguments: "-file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Command with two quoted paths", + Input: "cmd /c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedArguments: "/c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable EscapedQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Path with escaped quotes in folder name", + Input: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing escaped quote", + Input: "\"C:\\Windows\\\\\\\"\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: "C:\\Windows\\", + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable PartialMalformedQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Unclosed quote at start", + Input: "\"C:\\Program Files\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quote in middle of unquoted path", + Input: "C:\\Some\\\"Path\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Some\\\"Path\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Unclosed quote - never ends", + Input: "\"Starts quoted but never ends", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "Starts quoted but never ends", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable EnvironmentVariablesWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted environment variable path with spaces", + Input: "\"%ProgramFiles%\\MyApp\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "MyApp", "app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted USERPROFILE with document path", + Input: "\"%USERPROFILE%\\Documents\\file with spaces.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "file with spaces.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Environment variable with trailing args", + Input: "\"%ProgramFiles%\\App\" with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"), + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Environment variable with trailing args", + Input: "%ProgramFiles%\\App with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"), + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + ]; + + public static IEnumerable ShellProtocolPathsWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted shell:Downloads", + Input: "\"shell:Downloads\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted shell:Downloads with subpath", + Input: "\"shell:Downloads\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "file.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Shell Desktop with subpath", + Input: "shell:Desktop\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "file.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted shell path with trailing text", + Input: "\"shell:Programs\" extra", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(paths: Environment.GetFolderPath(Environment.SpecialFolder.Programs)), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable CommandFlagsAndOptions() => + [ + [ + new PlaceholderClassificationCase( + Name: "Path followed by flag with quoted value", + Input: "C:\\app.exe -flag \"value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\app.exe", + ExpectedArguments: "-flag \"value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted tool with equals-style flag", + Input: "\"C:\\Program Files\\tool.exe\" --input=file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\tool.exe", + ExpectedArguments: "--input=file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Path with slash option and quoted value", + Input: "C:\\tool.exe /option \"quoted value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\tool.exe", + ExpectedArguments: "/option \"quoted value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Flag before quoted path", + Input: "--path \"C:\\Program Files\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "--path", + ExpectedArguments: "\"C:\\Program Files\\app.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable NetworkPathsUnc() => + [ + [ + new PlaceholderClassificationCase( + Name: "UNC path unquoted", + Input: "\\\\server\\share\\folder\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "\\\\server\\share\\folder\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UNC path with spaces", + Input: "\"\\\\server\\share with spaces\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "\\\\server\\share with spaces\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "UNC path with trailing args", + Input: "\"\\\\server\\share\\\" with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "\\\\server\\share\\", + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UNC app with flag", + Input: "\"\\\\server\\My Share\\app.exe\" --flag", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "\\\\server\\My Share\\app.exe", + ExpectedArguments: "--flag", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable RelativePathsWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted relative current path", + Input: "\".\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted relative parent path", + Input: "\"..\\parent folder\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted relative home folder", + Input: "\"~\\current folder\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "current folder\\app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable EmptyAndWhitespaceCases() => + [ + [ + new PlaceholderClassificationCase( + Name: "Empty string", + Input: string.Empty, + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Only whitespace", + Input: " ", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: " ", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Just empty quotes", + Input: "\"\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted single space", + Input: "\" \"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: " ", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable RealWorldCommandScenarios() => + [ +#if CMDPAL_ENABLE_UNSAFE_TESTS + [ + new PlaceholderClassificationCase( + Name: "Git clone command with full exe path with quoted path", + Input: "\"C:\\Program Files\\Git\\bin\\git.exe\" clone repo", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\Git\\bin\\git.exe", + ExpectedArguments: "clone repo", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Git clone command with quoted path", + Input: "git clone repo", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Program Files\\Git\\cmd\\git.EXE", + ExpectedArguments: "clone repo", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Visual Studio devenv with solution", + Input: "\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe\" solution.sln", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe", + ExpectedArguments: "solution.sln", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Double-quoted Windows cmd pattern", + Input: "cmd /c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedArguments: "/c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"", + ExpectedIsPlaceholder: false) + ], +#endif + [ + new PlaceholderClassificationCase( + Name: "PowerShell script with execution policy", + Input: "powershell -ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\PowerShell.exe", + ExpectedArguments: "-ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + ]; + + public static IEnumerable SpecialCharactersInPaths() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted path with square brackets", + Input: "\"C:\\Path\\file[1].txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Path\\file[1].txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with parentheses", + Input: "\"C:\\Folder (2)\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Folder (2)\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with hyphens and underscores", + Input: "\"C:\\Path\\file_name-123.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Path\\file_name-123.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable QuotedPathsCurrentlyBroken() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted path with spaces - complete path", + Input: "\"C:\\Program Files\\MyApp\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\MyApp\\app.exe", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with spaces in user folder", + Input: "\"C:\\Users\\John Doe\\Documents\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Users\\John Doe\\Documents\\file.txt", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing arguments", + Input: "\"C:\\Program Files\\app.exe\" --flag", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedArguments: "--flag", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with multiple arguments", + Input: "\"C:\\My Documents\\file.txt\" -output result.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\My Documents\\file.txt", + ExpectedArguments: "-output result.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing flag and value", + Input: "\"C:\\Tools\\converter.exe\" input.txt output.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Tools\\converter.exe", + ExpectedArguments: "input.txt output.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable QuotedPathsInCommands() => + [ + [ + new PlaceholderClassificationCase( + Name: "cmd /c with quoted path", + Input: "cmd /c \"C:\\Program Files\\tool.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.exe", + ExpectedArguments: "/c \"C:\\Program Files\\tool.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "PowerShell with quoted script path", + Input: "powershell -File \"C:\\Scripts\\my script.ps1\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", path4: "powershell.exe"), + ExpectedArguments: "-File \"C:\\Scripts\\my script.ps1\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "runas with quoted executable", + Input: "runas /user:admin \"C:\\Windows\\System32\\cmd.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\runas.exe", + ExpectedArguments: "/user:admin \"C:\\Windows\\System32\\cmd.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable QuotedAumid() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted UWP AUMID via AppsFolder", + Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UWP AUMID with AppsFolder prefix and argument", + Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\" --maximized", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedArguments: "--maximized", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + ]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs new file mode 100644 index 0000000000..16378a7cd7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs @@ -0,0 +1,102 @@ +// 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. + +#nullable enable + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private static string _testDirPath; + private static string _userHomeDirPath; + private static string _testDirName; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + _userHomeDirPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _testDirName = "CmdPalBookmarkTests" + Guid.NewGuid().ToString("N"); + _testDirPath = Path.Combine(_userHomeDirPath, _testDirName); + Directory.CreateDirectory(_testDirPath); + + // test files in user home + File.WriteAllText(Path.Combine(_userHomeDirPath, "file.txt"), "This is a test text file."); + + // test files in test dir + File.WriteAllText(Path.Combine(_testDirPath, "file.txt"), "This is a test text file."); + File.WriteAllText(Path.Combine(_testDirPath, "app.exe"), "This is a test text file."); + } + + [ClassCleanup] + public static void ClassCleanup() + { + if (Directory.Exists(_testDirPath)) + { + Directory.Delete(_testDirPath, true); + } + + if (File.Exists(Path.Combine(_userHomeDirPath, "file.txt"))) + { + File.Delete(Path.Combine(_userHomeDirPath, "file.txt")); + } + } + + // must be public static to be used as DataTestMethod data source + public static string FromCase(MethodInfo method, object[] data) + => data is [PlaceholderClassificationCase c] + ? c.Name + : $"{method.Name}({string.Join(", ", data.Select(row => row.ToString()))})"; + + private static async Task RunShared(PlaceholderClassificationCase c) + { + // Arrange + IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser()); + + // Act + var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None); + + // Assert + Assert.IsNotNull(classification); + Assert.AreEqual(c.ExpectSuccess, classification.Success, "Success flag mismatch."); + + if (c.ExpectSuccess) + { + Assert.IsNotNull(classification.Result, "Result should not be null for successful classification."); + Assert.AreEqual(c.ExpectedKind, classification.Result.Kind, $"CommandKind mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedTarget, classification.Result.Target, StringComparer.OrdinalIgnoreCase, $"Target mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedLaunch, classification.Result.Launch, $"LaunchMethod mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedArguments, classification.Result.Arguments, $"Arguments mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder, $"IsPlaceholder mismatch for input: {c.Input}"); + + if (c.ExpectedDisplayName != null) + { + Assert.AreEqual(c.ExpectedDisplayName, classification.Result.DisplayName, $"DisplayName mismatch for input: {c.Input}"); + } + } + } + + public sealed record PlaceholderClassificationCase( + string Name, // Friendly name for Test Explorer + string Input, // Input string passed to classifier + bool ExpectSuccess, // Expected Success flag + CommandKind ExpectedKind, // Expected Result.Kind + string ExpectedTarget, // Expected Result.Target (normalized) + LaunchMethod ExpectedLaunch, // Expected Result.Launch + bool ExpectedIsPlaceholder, // Expected Result.IsPlaceholder + string ExpectedArguments = "", // Expected Result.Arguments + string? ExpectedDisplayName = null // Expected Result.DisplayName + ); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs index 52f50727a7..82b961649c 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs @@ -2,9 +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.Collections.Generic; using System.Linq; -using Microsoft.CmdPal.Ext.Bookmarks; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; @@ -16,8 +16,8 @@ public class BookmarksCommandProviderTests public void ProviderHasCorrectId() { // Setup - var mockDataSource = new MockBookmarkDataSource(); - var provider = new BookmarksCommandProvider(mockDataSource); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Assert Assert.AreEqual("Bookmarks", provider.Id); @@ -27,8 +27,8 @@ public class BookmarksCommandProviderTests public void ProviderHasDisplayName() { // Setup - var mockDataSource = new MockBookmarkDataSource(); - var provider = new BookmarksCommandProvider(mockDataSource); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Assert Assert.IsNotNull(provider.DisplayName); @@ -39,7 +39,8 @@ public class BookmarksCommandProviderTests public void ProviderHasIcon() { // Setup - var provider = new BookmarksCommandProvider(); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Assert Assert.IsNotNull(provider.Icon); @@ -49,7 +50,8 @@ public class BookmarksCommandProviderTests public void TopLevelCommandsNotEmpty() { // Setup - var provider = new BookmarksCommandProvider(); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Act var commands = provider.TopLevelCommands(); @@ -60,47 +62,40 @@ public class BookmarksCommandProviderTests } [TestMethod] - public void ProviderWithMockData_LoadsBookmarksCorrectly() + [Timeout(5000)] + public async Task ProviderWithMockData_LoadsBookmarksCorrectly() { // Arrange - var jsonData = @"{ - ""Data"": [ - { - ""Name"": ""Test Bookmark"", - ""Bookmark"": ""https://test.com"" - }, - { - ""Name"": ""Another Bookmark"", - ""Bookmark"": ""https://another.com"" - } - ] - }"; - - var dataSource = new MockBookmarkDataSource(jsonData); - var provider = new BookmarksCommandProvider(dataSource); + var mockBookmarkManager = new MockBookmarkManager( + new BookmarkData("Test Bookmark", "http://test.com"), + new BookmarkData("Another Bookmark", "http://another.com")); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Act var commands = provider.TopLevelCommands(); // Assert - Assert.IsNotNull(commands); - - var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); - var testBookmark = commands.Where(c => c.Title.Contains("Test Bookmark")).FirstOrDefault(); + Assert.IsNotNull(commands, "commands != null"); // Should have three commands:Add + two custom bookmarks Assert.AreEqual(3, commands.Length); - Assert.IsNotNull(addCommand); - Assert.IsNotNull(testBookmark); + // Wait until all BookmarkListItem commands are initialized + await Task.WhenAll(commands.OfType().Select(t => t.IsInitialized)); + + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); + var testBookmark = commands.FirstOrDefault(c => c.Title.Contains("Test Bookmark")); + + Assert.IsNotNull(addCommand, "addCommand != null"); + Assert.IsNotNull(testBookmark, "testBookmark != null"); } [TestMethod] public void ProviderWithEmptyData_HasOnlyAddCommand() { // Arrange - var dataSource = new MockBookmarkDataSource(@"{ ""Data"": [] }"); - var provider = new BookmarksCommandProvider(dataSource); + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); // Act var commands = provider.TopLevelCommands(); @@ -111,7 +106,7 @@ public class BookmarksCommandProviderTests // Only have Add command Assert.AreEqual(1, commands.Length); - var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); Assert.IsNotNull(addCommand); } @@ -120,7 +115,7 @@ public class BookmarksCommandProviderTests { // Arrange var dataSource = new MockBookmarkDataSource("invalid json"); - var provider = new BookmarksCommandProvider(dataSource); + var provider = new BookmarksCommandProvider(new MockBookmarkManager()); // Act var commands = provider.TopLevelCommands(); @@ -131,7 +126,7 @@ public class BookmarksCommandProviderTests // Only have one command. Will ignore json parse error. Assert.AreEqual(1, commands.Length); - var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); Assert.IsNotNull(addCommand); } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs new file mode 100644 index 0000000000..977f3b5006 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs @@ -0,0 +1,268 @@ +// 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. + +#nullable enable +using System; +using System.IO; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class CommandLineHelperTests +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private static string _tempTestDir; + + private static string _tempTestFile; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + // Create temporary test directory and file + _tempTestDir = Path.Combine(Path.GetTempPath(), "CommandLineHelperTests_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempTestDir); + + _tempTestFile = Path.Combine(_tempTestDir, "testfile.txt"); + File.WriteAllText(_tempTestFile, "test"); + } + + [ClassCleanup] + public static void ClassCleanup() + { + // Clean up test directory + if (Directory.Exists(_tempTestDir)) + { + Directory.Delete(_tempTestDir, true); + } + } + + [TestMethod] + [DataRow("%TEMP%", false, true, DisplayName = "Expands TEMP environment variable")] + [DataRow("%USERPROFILE%", false, true, DisplayName = "Expands USERPROFILE environment variable")] + [DataRow("%SystemRoot%", false, true, DisplayName = "Expands SystemRoot environment variable")] + public void Expand_WithEnvironmentVariables_ExpandsCorrectly(string input, bool expandShell, bool shouldExist) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.AreEqual(shouldExist, result, $"Expected result {shouldExist} for input '{input}'"); + if (shouldExist) + { + Assert.IsFalse(full.Contains('%'), "Output should not contain % symbols after expansion"); + Assert.IsTrue(Path.Exists(full), $"Expanded path '{full}' should exist"); + } + } + + [TestMethod] + [DataRow("shell:Downloads", true, DisplayName = "Expands shell:Downloads when expandShell is true")] + [DataRow("shell:Desktop", true, DisplayName = "Expands shell:Desktop when expandShell is true")] + [DataRow("shell:Documents", true, DisplayName = "Expands shell:Documents when expandShell is true")] + public void Expand_WithShellPaths_ExpandsWhenFlagIsTrue(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + if (result) + { + Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved"); + Assert.IsTrue(Path.Exists(full), $"Expanded shell path '{full}' should exist"); + } + + // Note: Result may be false if ShellNames.TryGetFileSystemPath fails + } + + [TestMethod] + [DataRow("shell:Personal", false, DisplayName = "Does not expand shell: when expandShell is false")] + public void Expand_WithShellPaths_DoesNotExpandWhenFlagIsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert - shell: paths won't exist as literal paths + Assert.IsFalse(result, "Should return false for unexpanded shell path"); + Assert.AreEqual(input, full, "Output should match input when not expanding shell paths"); + } + + [TestMethod] + [DataRow("shell:Personal\\subfolder", true, "\\subfolder", DisplayName = "Expands shell path with subfolder")] + [DataRow("shell:Desktop\\test.txt", true, "\\test.txt", DisplayName = "Expands shell path with file")] + public void Expand_WithShellPathsAndSubpaths_CombinesCorrectly(string input, bool expandShell, string expectedEnding) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Note: Result depends on whether the combined path exists + if (result) + { + Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved"); + Assert.IsTrue(full.EndsWith(expectedEnding, StringComparison.OrdinalIgnoreCase), "Output should end with the subpath"); + } + } + + [TestMethod] + public void Expand_WithExistingDirectory_ReturnsFullPath() + { + // Arrange + var input = _tempTestDir; + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full); + + // Assert + Assert.IsTrue(result, "Should return true for existing directory"); + Assert.AreEqual(Path.GetFullPath(_tempTestDir), full, "Should return full path"); + } + + [TestMethod] + public void Expand_WithExistingFile_ReturnsFullPath() + { + // Arrange + var input = _tempTestFile; + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full); + + // Assert + Assert.IsTrue(result, "Should return true for existing file"); + Assert.AreEqual(Path.GetFullPath(_tempTestFile), full, "Should return full path"); + } + + [TestMethod] + [DataRow("C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", false, "C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", DisplayName = "Nonexistent absolute path")] + [DataRow("NonExistentFile.txt", false, "NonExistentFile.txt", DisplayName = "Nonexistent relative path")] + public void Expand_WithNonExistentPath_ReturnsFalse(string input, bool expandShell, string expectedFull) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.IsFalse(result, "Should return false for nonexistent path"); + Assert.AreEqual(expectedFull, full, "Output should be empty string"); + } + + [TestMethod] + [DataRow("", false, DisplayName = "Empty string")] + [DataRow(" ", false, DisplayName = "Whitespace only")] + public void Expand_WithEmptyOrWhitespace_ReturnsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.IsFalse(result, "Should return false for empty/whitespace input"); + } + + [TestMethod] + [DataRow("%TEMP%\\testsubdir", false, DisplayName = "Env var with subdirectory")] + [DataRow("%USERPROFILE%\\Desktop", false, DisplayName = "USERPROFILE with Desktop")] + public void Expand_WithEnvironmentVariableAndSubpath_ExpandsCorrectly(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Result depends on whether the path exists + if (result) + { + Assert.IsFalse(full.Contains('%'), "Should expand environment variables"); + Assert.IsTrue(Path.Exists(full), "Expanded path should exist"); + } + } + + [TestMethod] + public void Expand_WithRelativePath_ConvertsToAbsoluteWhenExists() + { + // Arrange + var relativePath = Path.GetRelativePath(Environment.CurrentDirectory, _tempTestDir); + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(relativePath, false, out var full); + + // Assert + if (result) + { + Assert.IsTrue(Path.IsPathRooted(full), "Output should be absolute path"); + Assert.IsTrue(Path.Exists(full), "Expanded path should exist"); + } + } + + [TestMethod] + [DataRow("InvalidShell:Path", true, DisplayName = "Invalid shell path format")] + public void Expand_WithInvalidShellPath_ReturnsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + // If ShellNames.TryGetFileSystemPath returns false, method returns false + Assert.IsFalse(result || Path.Exists(full), "Should return false or path should not exist"); + } + + [DataTestMethod] + + // basic + [DataRow("cmd ping", "cmd", "ping")] + [DataRow("cmd ping pong", "cmd", "ping pong")] + [DataRow("cmd \"ping pong\"", "cmd", "\"ping pong\"")] + + // no tail / trailing whitespace after head + [DataRow("cmd", "cmd", "")] + [DataRow("cmd ", "cmd", "")] + + // spacing & tabs between args should be preserved in tail + [DataRow("cmd ping pong", "cmd", "ping pong")] + [DataRow("cmd\tping\tpong", "cmd", "ping\tpong")] + + // leading whitespace before head + [DataRow(" cmd ping", "", "cmd ping")] + [DataRow("\t cmd ping", "", "cmd ping")] + + // quoted tail variants + [DataRow("cmd \"\"", "cmd", "\"\"")] + [DataRow("cmd \"a \\\"quoted\\\" arg\" b", "cmd", "\"a \\\"quoted\\\" arg\" b")] + + // quoted head (spaces in path) + [DataRow(@"""C:\Program Files\nodejs\node.exe"" -v", @"C:\Program Files\nodejs\node.exe", "-v")] + [DataRow(@"""C:\Program Files\Git\bin\bash.exe""", @"C:\Program Files\Git\bin\bash.exe", "")] + [DataRow(@" ""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""", @"", @"""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""")] + [DataRow(@"""C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe"" Test.sln", @"C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe", "Test.sln")] + + // quoted simple head (still should strip quotes for head) + [DataRow(@"""cmd"" ping", "cmd", "ping")] + + // common CLI shapes + [DataRow("git --version", "git", "--version")] + [DataRow("dotnet build -c Release", "dotnet", "build -c Release")] + + // UNC paths + [DataRow("\"\\\\server\\share\\\" with args", "\\\\server\\share\\", "with args")] + public void SplitHeadAndArgs(string input, string expectedHead, string expectedTail) + { + // Act + var result = CommandLineHelper.SplitHeadAndArgs(input); + + // Assert + // If ShellNames.TryGetFileSystemPath returns false, method returns false + Assert.AreEqual(expectedHead, result.Head); + Assert.AreEqual(expectedTail, result.Tail); + } + + [DataTestMethod] + [DataRow(@"C:\program files\myapp\app.exe -param ""1"" -param 2", @"C:\program files\myapp\app.exe -param", @"""1"" -param 2")] + [DataRow(@"git commit -m test", "git commit -m test", "")] + [DataRow(@"""C:\Program Files\App\app.exe"" -v", "", @"""C:\Program Files\App\app.exe"" -v")] + [DataRow(@"tool a\\\""b c ""d e"" f", @"tool a\\\""b c", @"""d e"" f")] // escaped quote before first real one + [DataRow("C:\\Some\\\"Path\\file.txt", "C:\\Some\\\"Path\\file.txt", "")] + [DataRow(@" ""C:\p\app.exe"" -v", "", @"""C:\p\app.exe"" -v")] // first token is quoted + public void SplitLongestHeadBeforeQuotedArg_Tests(string input, string expectedHead, string expectedTail) + { + var (head, tail) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedTail, tail); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs index ae3732559c..3980ac13c6 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; internal sealed class MockBookmarkDataSource : IBookmarkDataSource diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs new file mode 100644 index 0000000000..b3e48db791 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs @@ -0,0 +1,35 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +#pragma warning disable CS0067 + +internal sealed class MockBookmarkManager : IBookmarksManager +{ + private readonly List _bookmarks; + + public event Action BookmarkAdded; + + public event Action BookmarkUpdated; + + public event Action BookmarkRemoved; + + public IReadOnlyCollection Bookmarks => _bookmarks; + + public BookmarkData Add(string name, string bookmark) => throw new NotImplementedException(); + + public bool Remove(Guid id) => throw new NotImplementedException(); + + public BookmarkData Update(Guid id, string name, string bookmark) => throw new NotImplementedException(); + + public MockBookmarkManager(params IEnumerable bookmarks) + { + _bookmarks = [.. bookmarks]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs new file mode 100644 index 0000000000..b7e5933aa8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs @@ -0,0 +1,108 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class PlaceholderInfoNameEqualityComparerTests +{ + [TestMethod] + public void Equals_BothNull_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + + var result = comparer.Equals(null, null); + + Assert.IsTrue(result); + } + + [TestMethod] + public void Equals_OneNull_ReturnsFalse() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p = new PlaceholderInfo("name", 0); + + Assert.IsFalse(comparer.Equals(p, null)); + Assert.IsFalse(comparer.Equals(null, p)); + } + + [TestMethod] + public void Equals_SameNameDifferentIndex_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("name", 0); + var p2 = new PlaceholderInfo("name", 10); + + Assert.IsTrue(comparer.Equals(p1, p2)); + } + + [TestMethod] + public void Equals_DifferentNameSameIndex_ReturnsFalse() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("first", 3); + var p2 = new PlaceholderInfo("second", 3); + + Assert.IsFalse(comparer.Equals(p1, p2)); + } + + [TestMethod] + public void Equals_CaseInsensitive_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("Name", 0); + var p2 = new PlaceholderInfo("name", 5); + + Assert.IsTrue(comparer.Equals(p1, p2)); + Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2)); + } + + [TestMethod] + public void GetHashCode_SameNameDifferentIndex_SameHash() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("same", 1); + var p2 = new PlaceholderInfo("same", 99); + + Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2)); + } + + [TestMethod] + public void GetHashCode_Null_ThrowsArgumentNullException() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + Assert.ThrowsException(() => comparer.GetHashCode(null!)); + } + + [TestMethod] + public void Instance_ReturnsSingleton() + { + var a = PlaceholderInfoNameEqualityComparer.Instance; + var b = PlaceholderInfoNameEqualityComparer.Instance; + + Assert.IsNotNull(a); + Assert.AreSame(a, b); + } + + [TestMethod] + public void HashSet_UsesNameEquality_IgnoresIndex() + { + var set = new HashSet(PlaceholderInfoNameEqualityComparer.Instance) + { + new("dup", 0), + new("DUP", 10), + new("unique", 0), + }; + + Assert.AreEqual(2, set.Count); + Assert.IsTrue(set.Contains(new PlaceholderInfo("dup", 123))); + Assert.IsTrue(set.Contains(new PlaceholderInfo("UNIQUE", 999))); + Assert.IsFalse(set.Contains(new PlaceholderInfo("missing", 0))); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs new file mode 100644 index 0000000000..31abeb0195 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs @@ -0,0 +1,177 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class PlaceholderParserTests +{ + private IPlaceholderParser _parser; + + [TestInitialize] + public void Setup() + { + _parser = new PlaceholderParser(); + } + + public static IEnumerable ValidPlaceholderTestData => + [ + [ + "Hello {name}!", + true, + "Hello ", + new[] { "name" }, + new[] { 6 } + ], + [ + "User {user_name} has {count} items", + true, + "User ", + new[] { "user_name", "count" }, + new[] { 5, 21 } + ], + [ + "Order {order-id} for {name} by {name}", + true, + "Order ", + new[] { "order-id", "name", "name" }, + new[] { 6, 21, 31 } + ], + [ + "{start} and {end}", + true, + string.Empty, + new[] { "start", "end" }, + new[] { 0, 12 } + ], + [ + "Number {123} and text {abc}", + true, + "Number ", + new[] { "123", "abc" }, + new[] { 7, 22 } + ] + ]; + + public static IEnumerable InvalidPlaceholderTestData => + [ + [string.Empty, false, string.Empty, Array.Empty()], + ["No placeholders here", false, "No placeholders here", Array.Empty()], + ["GUID: {550e8400-e29b-41d4-a716-446655440000}", false, "GUID: {550e8400-e29b-41d4-a716-446655440000}", Array.Empty()], + ["Invalid {user.name} placeholder", false, "Invalid {user.name} placeholder", Array.Empty()], + ["Empty {} placeholder", false, "Empty {} placeholder", Array.Empty()], + ["Unclosed {placeholder", false, "Unclosed {placeholder", Array.Empty()], + ["No opening brace placeholder}", false, "No opening brace placeholder}", Array.Empty()], + ["Invalid chars {user@domain}", false, "Invalid chars {user@domain}", Array.Empty()], + ["Spaces { name }", false, "Spaces { name }", Array.Empty()] + ]; + + [TestMethod] + [DynamicData(nameof(ValidPlaceholderTestData))] + public void ParsePlaceholders_ValidInput_ReturnsExpectedResults( + string input, + bool expectedResult, + string expectedHead, + string[] expectedPlaceholderNames, + int[] expectedIndexes) + { + // Act + var result = _parser.ParsePlaceholders(input, out var head, out var placeholders); + + // Assert + Assert.AreEqual(expectedResult, result); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count); + + var actualNames = placeholders.Select(p => p.Name).ToArray(); + var actualIndexes = placeholders.Select(p => p.Index).ToArray(); + + // Validate names and indexes (allow duplicates, ignore order) + CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames); + CollectionAssert.AreEquivalent(expectedIndexes, actualIndexes); + + // Validate name-index pairing exists for each expected placeholder occurrence + for (var i = 0; i < expectedPlaceholderNames.Length; i++) + { + var expectedName = expectedPlaceholderNames[i]; + var expectedIndex = expectedIndexes[i]; + Assert.IsTrue( + placeholders.Any(p => p.Name == expectedName && p.Index == expectedIndex), + $"Expected placeholder '{{{expectedName}}}' at index {expectedIndex} was not found."); + } + } + + [TestMethod] + [DynamicData(nameof(InvalidPlaceholderTestData))] + public void ParsePlaceholders_InvalidInput_ReturnsExpectedResults( + string input, + bool expectedResult, + string expectedHead, + string[] expectedPlaceholderNames) + { + // Act + var result = _parser.ParsePlaceholders(input, out var head, out var placeholders); + + // Assert + Assert.AreEqual(expectedResult, result); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count); + + var actualNames = placeholders.Select(p => p.Name).ToArray(); + CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames); + } + + [TestMethod] + public void ParsePlaceholders_NullInput_ThrowsArgumentNullException() + { + Assert.ThrowsException(() => _parser.ParsePlaceholders(null!, out _, out _)); + } + + [TestMethod] + public void Placeholder_Equality_WorksCorrectly() + { + // Arrange + var placeholder1 = new PlaceholderInfo("name", 0); + var placeholder2 = new PlaceholderInfo("name", 0); + var placeholder3 = new PlaceholderInfo("other", 0); + var placeholder4 = new PlaceholderInfo("name", 1); + + // Assert + Assert.AreEqual(placeholder1, placeholder2); + Assert.AreNotEqual(placeholder1, placeholder3); + Assert.AreEqual(placeholder1.GetHashCode(), placeholder2.GetHashCode()); + Assert.AreNotEqual(placeholder1, placeholder4); + Assert.AreNotEqual(placeholder1.GetHashCode(), placeholder4.GetHashCode()); + } + + [TestMethod] + public void Placeholder_ToString_ReturnsName() + { + // Arrange + var placeholder = new PlaceholderInfo("userName", 0); + + // Assert + Assert.AreEqual("userName", placeholder.ToString()); + } + + [TestMethod] + public void Placeholder_Constructor_ThrowsOnNull() + { + // Assert + Assert.ThrowsException(() => new PlaceholderInfo(null!, 0)); + } + + [TestMethod] + public void Placeholder_Constructor_ThrowsArgumentOutOfRange() + { + // Assert + Assert.ThrowsException(() => new PlaceholderInfo("Name", -1)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs index 767460fa27..e079be0655 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs @@ -40,16 +40,4 @@ public class QueryTests : CommandPaletteUnitTestBase Assert.IsNotNull(githubBookmark); Assert.AreEqual("https://github.com", githubBookmark.Bookmark); } - - [TestMethod] - public void ValidateWebUrlDetection() - { - // Setup - var bookmarks = Settings.CreateDefaultBookmarks(); - var microsoftBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "Microsoft"); - - // Assert - Assert.IsNotNull(microsoftBookmark); - Assert.IsTrue(microsoftBookmark.IsWebUrl()); - } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs index 82d7cd1cad..3bfd7391d0 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs @@ -2,13 +2,15 @@ // 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.Bookmarks.Persistence; + namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; public static class Settings { - public static Bookmarks CreateDefaultBookmarks() + public static BookmarksData CreateDefaultBookmarks() { - var bookmarks = new Bookmarks(); + var bookmarks = new BookmarksData(); // Add some test bookmarks bookmarks.Data.Add(new BookmarkData diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs new file mode 100644 index 0000000000..4731cfeddc --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs @@ -0,0 +1,120 @@ +// 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 Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class UriHelperTests +{ + private static bool TryGetScheme(ReadOnlySpan input, out string scheme, out string remainder) + { + return UriHelper.TryGetScheme(input, out scheme, out remainder); + } + + [DataTestMethod] + [DataRow("http://example.com", "http", "//example.com")] + [DataRow("ftp:", "ftp", "")] + [DataRow("my-app:payload", "my-app", "payload")] + [DataRow("x-cmdpal://settings/", "x-cmdpal", "//settings/")] + [DataRow("custom+ext.-scheme:xyz", "custom+ext.-scheme", "xyz")] + [DataRow("MAILTO:foo@bar", "MAILTO", "foo@bar")] + [DataRow("a:b", "a", "b")] + public void TryGetScheme_ValidSchemes_ReturnsTrueAndSplits(string input, string expectedScheme, string expectedRemainder) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok, "Expected valid scheme."); + Assert.AreEqual(expectedScheme, scheme); + Assert.AreEqual(expectedRemainder, remainder); + } + + [TestMethod] + public void TryGetScheme_OnlySchemeAndColon_ReturnsEmptyRemainder() + { + var ok = TryGetScheme("http:".AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("http", scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [DataTestMethod] + [DataRow("123:http")] // starts with digit + [DataRow(":nope")] // colon at start + [DataRow("noColon")] // no colon at all + [DataRow("bad_scheme:")] // underscore not allowed + [DataRow("bad*scheme:")] // asterisk not allowed + [DataRow(":")] // syntactically invalid literal just for completeness; won't compile, example only + public void TryGetScheme_InvalidInputs_ReturnsFalse(string input) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsFalse(ok); + Assert.AreEqual(string.Empty, scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [TestMethod] + public void TryGetScheme_MultipleColons_SplitsOnFirst() + { + const string input = "shell:::{645FF040-5081-101B-9F08-00AA002F954E}"; + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("shell", scheme); + Assert.AreEqual("::{645FF040-5081-101B-9F08-00AA002F954E}", remainder); + } + + [TestMethod] + public void TryGetScheme_MinimumLength_OneLetterAndColon() + { + var ok = TryGetScheme("a:".AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("a", scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [TestMethod] + public void TryGetScheme_TooShort_ReturnsFalse() + { + Assert.IsFalse(TryGetScheme("a".AsSpan(), out _, out _), "No colon."); + Assert.IsFalse(TryGetScheme(":".AsSpan(), out _, out _), "Colon at start; no scheme."); + } + + [DataTestMethod] + [DataRow("HTTP://x", "HTTP", "//x")] + [DataRow("hTtP:rest", "hTtP", "rest")] + public void TryGetScheme_CaseIsPreserved(string input, string expectedScheme, string expectedRemainder) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual(expectedScheme, scheme); + Assert.AreEqual(expectedRemainder, remainder); + } + + [TestMethod] + public void TryGetScheme_WhitespaceInsideScheme_Fails() + { + Assert.IsFalse(TryGetScheme("ht tp:rest".AsSpan(), out _, out _)); + } + + [TestMethod] + public void TryGetScheme_PlusMinusDot_AllowedInMiddleOnly() + { + Assert.IsTrue(TryGetScheme("a+b.c-d:rest".AsSpan(), out var s1, out var r1)); + Assert.AreEqual("a+b.c-d", s1); + Assert.AreEqual("rest", r1); + + // The first character must be a letter; plus is not allowed as first char + Assert.IsFalse(TryGetScheme("+abc:rest".AsSpan(), out _, out _)); + Assert.IsFalse(TryGetScheme(".abc:rest".AsSpan(), out _, out _)); + Assert.IsFalse(TryGetScheme("-abc:rest".AsSpan(), out _, out _)); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs deleted file mode 100644 index bf92a4413b..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Text.Json.Serialization; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public class BookmarkData -{ - public string Name { get; set; } = string.Empty; - - public string Bookmark { 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; - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs deleted file mode 100644 index 965f42d1b0..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs +++ /dev/null @@ -1,92 +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.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class BookmarkPlaceholderForm : FormContent -{ - private static readonly CompositeFormat ErrorMessage = System.Text.CompositeFormat.Parse(Resources.bookmarks_required_placeholder); - - private readonly List _placeholderNames; - - private readonly string _bookmark = string.Empty; - - // TODO pass in an array of placeholders - public BookmarkPlaceholderForm(string name, string url) - { - _bookmark = url; - var r = new Regex(Regex.Escape("{") + "(.*?)" + Regex.Escape("}")); - var matches = r.Matches(url); - _placeholderNames = matches.Select(m => m.Groups[1].Value).ToList(); - var inputs = _placeholderNames.Select(p => - { - var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, p); - return $$""" -{ - "type": "Input.Text", - "style": "text", - "id": "{{p}}", - "label": "{{p}}", - "isRequired": true, - "errorMessage": "{{errorMessage}}" -} -"""; - }).ToList(); - - var allInputs = string.Join(",", inputs); - - TemplateJson = $$""" -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.5", - "body": [ -""" + allInputs + $$""" - ], - "actions": [ - { - "type": "Action.Submit", - "title": "{{Resources.bookmarks_form_open}}", - "data": { - "placeholder": "placeholder" - } - } - ] -} -"""; - } - - public override CommandResult SubmitForm(string payload) - { - var target = _bookmark; - - // parse the submitted JSON and then open the link - var formInput = JsonNode.Parse(payload); - var formObject = formInput?.AsObject(); - if (formObject is null) - { - return CommandResult.GoHome(); - } - - foreach (var (key, value) in formObject) - { - var placeholderString = $"{{{key}}}"; - var placeholderData = value?.ToString(); - target = target.Replace(placeholderString, placeholderData); - } - - var success = UrlCommand.LaunchCommand(target); - - return success ? CommandResult.Dismiss() : CommandResult.KeepOpen(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs deleted file mode 100644 index 7cea160954..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class BookmarkPlaceholderPage : ContentPage -{ - private readonly Lazy _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) - { - } - - public BookmarkPlaceholderPage(string name, string url) - { - Name = Properties.Resources.bookmarks_command_name_open; - - _bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url); - - _icon = new Lazy(() => - { - ShellHelpers.ParseExecutableAndArgs(url, out var exe, out var args); - var t = UrlCommand.GetIconForPath(exe); - t.Wait(); - return t.Result; - }); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs index 1174685729..df926129fb 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs @@ -2,186 +2,129 @@ // 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.Diagnostics.Contracts; using System.IO; using System.Linq; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CmdPal.Ext.Indexer; +using System.Threading; +using Microsoft.CmdPal.Ext.Bookmarks.Pages; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Bookmarks; -public partial class BookmarksCommandProvider : CommandProvider +public sealed partial class BookmarksCommandProvider : CommandProvider { - private readonly List _commands = []; + private const int LoadStateNotLoaded = 0; + private const int LoadStateLoading = 1; + private const int LoadStateLoaded = 2; - private readonly AddBookmarkPage _addNewCommand = new(null); + private readonly IPlaceholderParser _placeholderParser = new PlaceholderParser(); + private readonly IBookmarksManager _bookmarksManager; + private readonly IBookmarkResolver _commandResolver; + private readonly IBookmarkIconLocator _iconLocator = new IconLocator(); - private readonly IBookmarkDataSource _dataSource; - private readonly BookmarkJsonParser _parser; - private Bookmarks? _bookmarks; + private readonly ListItem _addNewItem; + private readonly Lock _bookmarksLock = new(); - public BookmarksCommandProvider() - : this(new FileBookmarkDataSource(StateJsonPath())) + private ICommandItem[] _commands = []; + private List _bookmarks = []; + private int _loadState; + + private static string StateJsonPath() { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, "bookmarks.json"); } - internal BookmarksCommandProvider(IBookmarkDataSource dataSource) + public static BookmarksCommandProvider CreateWithDefaultStore() { - _dataSource = dataSource; - _parser = new BookmarkJsonParser(); + return new BookmarksCommandProvider(new BookmarksManager(new FileBookmarkDataSource(StateJsonPath()))); + } + + internal BookmarksCommandProvider(IBookmarksManager bookmarksManager) + { + ArgumentNullException.ThrowIfNull(bookmarksManager); + _bookmarksManager = bookmarksManager; + _bookmarksManager.BookmarkAdded += OnBookmarkAdded; + _bookmarksManager.BookmarkRemoved += OnBookmarkRemoved; + + _commandResolver = new BookmarkResolver(_placeholderParser); Id = "Bookmarks"; DisplayName = Resources.bookmarks_display_name; Icon = Icons.PinIcon; - _addNewCommand.AddedCommand += AddNewCommand_AddedCommand; + var addBookmarkPage = new AddBookmarkPage(null); + addBookmarkPage.AddedCommand += (_, e) => _bookmarksManager.Add(e.Name, e.Bookmark); + _addNewItem = new ListItem(addBookmarkPage); } - private void AddNewCommand_AddedCommand(object sender, BookmarkData args) + private void OnBookmarkAdded(BookmarkData bookmarkData) { - ExtensionHost.LogMessage($"Adding bookmark ({args.Name},{args.Bookmark})"); - _bookmarks?.Data.Add(args); + var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser); + lock (_bookmarksLock) + { + _bookmarks.Add(newItem); + } - SaveAndUpdateCommands(); + NotifyChange(); } - // In the edit path, `args` was already in _bookmarks, we just updated it - private void Edit_AddedCommand(object sender, BookmarkData args) + private void OnBookmarkRemoved(BookmarkData bookmarkData) { - ExtensionHost.LogMessage($"Edited bookmark ({args.Name},{args.Bookmark})"); - - SaveAndUpdateCommands(); - } - - private void SaveAndUpdateCommands() - { - try + lock (_bookmarksLock) { - var jsonData = _parser.SerializeBookmarks(_bookmarks); - _dataSource.SaveBookmarkData(jsonData); - } - catch (Exception ex) - { - Logger.LogError($"Failed to save bookmarks: {ex.Message}"); + _bookmarks.RemoveAll(t => t.BookmarkId == bookmarkData.Id); } - LoadCommands(); - RaiseItemsChanged(0); - } - - private void LoadCommands() - { - List collected = []; - collected.Add(new CommandItem(_addNewCommand)); - - if (_bookmarks is null) - { - LoadBookmarksFromFile(); - } - - if (_bookmarks is not null) - { - collected.AddRange(_bookmarks.Data.Select(BookmarkToCommandItem)); - } - - _commands.Clear(); - _commands.AddRange(collected); - } - - private void LoadBookmarksFromFile() - { - try - { - var jsonData = _dataSource.GetBookmarkData(); - _bookmarks = _parser.ParseBookmarks(jsonData); - } - catch (Exception ex) - { - Logger.LogError(ex.Message); - } - - if (_bookmarks is null) - { - _bookmarks = new(); - } - } - - private CommandItem BookmarkToCommandItem(BookmarkData bookmark) - { - ICommand command = bookmark.IsPlaceholder ? - new BookmarkPlaceholderPage(bookmark) : - new UrlCommand(bookmark); - - var listItem = new CommandItem(command) { Icon = command.Icon }; - - List contextMenu = []; - - // Add commands for folder types - if (command is UrlCommand urlCommand) - { - if (!bookmark.IsWebUrl()) - { - contextMenu.Add( - new CommandContextItem(new DirectoryPage(urlCommand.Url))); - - contextMenu.Add( - new CommandContextItem(new OpenInTerminalCommand(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)); - - var delete = new CommandContextItem( - title: Resources.bookmarks_delete_title, - name: Resources.bookmarks_delete_name, - action: () => - { - if (_bookmarks is not null) - { - ExtensionHost.LogMessage($"Deleting bookmark ({bookmark.Name},{bookmark.Bookmark})"); - - _bookmarks.Data.Remove(bookmark); - - SaveAndUpdateCommands(); - } - }, - result: CommandResult.KeepOpen()) - { - IsCritical = true, - Icon = Icons.DeleteIcon, - }; - contextMenu.Add(delete); - - listItem.MoreCommands = contextMenu.ToArray(); - - return listItem; + NotifyChange(); } public override ICommandItem[] TopLevelCommands() { - if (_commands.Count == 0) + if (Volatile.Read(ref _loadState) != LoadStateLoaded) { - LoadCommands(); + if (Interlocked.CompareExchange(ref _loadState, LoadStateLoading, LoadStateNotLoaded) == LoadStateNotLoaded) + { + try + { + lock (_bookmarksLock) + { + _bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))]; + _commands = BuildTopLevelCommandsUnsafe(); + } + + Volatile.Write(ref _loadState, LoadStateLoaded); + RaiseItemsChanged(); + } + catch + { + Volatile.Write(ref _loadState, LoadStateNotLoaded); + throw; + } + } } - return _commands.ToArray(); + return _commands; } - internal static string StateJsonPath() + private void NotifyChange() { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); + if (Volatile.Read(ref _loadState) != LoadStateLoaded) + { + return; + } - // now, the state is just next to the exe - return System.IO.Path.Combine(directory, "bookmarks.json"); + lock (_bookmarksLock) + { + _commands = BuildTopLevelCommandsUnsafe(); + } + + RaiseItemsChanged(); } + + [Pure] + private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs new file mode 100644 index 0000000000..1eb57fb7eb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs @@ -0,0 +1,141 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager +{ + private readonly IBookmarkDataSource _dataSource; + private readonly BookmarkJsonParser _parser = new(); + private readonly SupersedingAsyncGate _savingGate; + private readonly Lock _lock = new(); + private BookmarksData _bookmarksData = new(); + + public event Action? BookmarkAdded; + + public event Action? BookmarkUpdated; // old, new + + public event Action? BookmarkRemoved; + + public IReadOnlyCollection Bookmarks + { + get + { + lock (_lock) + { + return _bookmarksData.Data.ToList().AsReadOnly(); + } + } + } + + public BookmarksManager(IBookmarkDataSource dataSource) + { + ArgumentNullException.ThrowIfNull(dataSource); + _dataSource = dataSource; + _savingGate = new SupersedingAsyncGate(WriteData); + LoadBookmarksFromFile(); + } + + public BookmarkData Add(string name, string bookmark) + { + var newBookmark = new BookmarkData(name, bookmark); + + lock (_lock) + { + _bookmarksData.Data.Add(newBookmark); + _ = SaveChangesAsync(); + BookmarkAdded?.Invoke(newBookmark); + return newBookmark; + } + } + + public bool Remove(Guid id) + { + lock (_lock) + { + var bookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id); + if (bookmark != null && _bookmarksData.Data.Remove(bookmark)) + { + _ = SaveChangesAsync(); + BookmarkRemoved?.Invoke(bookmark); + return true; + } + + return false; + } + } + + public BookmarkData? Update(Guid id, string name, string bookmark) + { + lock (_lock) + { + var existingBookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id); + if (existingBookmark != null) + { + var updatedBookmark = existingBookmark with + { + Name = name, + Bookmark = bookmark, + }; + + var index = _bookmarksData.Data.IndexOf(existingBookmark); + _bookmarksData.Data[index] = updatedBookmark; + + _ = SaveChangesAsync(); + BookmarkUpdated?.Invoke(existingBookmark, updatedBookmark); + return updatedBookmark; + } + + return null; + } + } + + private void LoadBookmarksFromFile() + { + try + { + var jsonData = _dataSource.GetBookmarkData(); + _bookmarksData = _parser.ParseBookmarks(jsonData); + } + catch (Exception ex) + { + Logger.LogError(ex.Message); + } + } + + private Task WriteData(CancellationToken arg) + { + List dataToSave; + lock (_lock) + { + dataToSave = _bookmarksData.Data.ToList(); + } + + try + { + var jsonData = _parser.SerializeBookmarks(new BookmarksData { Data = dataToSave }); + _dataSource.SaveBookmarkData(jsonData); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save bookmarks: {ex.Message}"); + } + + return Task.CompletedTask; + } + + private async Task SaveChangesAsync() + { + await _savingGate.ExecuteAsync(CancellationToken.None); + } + + public void Dispose() => _savingGate.Dispose(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs new file mode 100644 index 0000000000..d6087b1481 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Commands; + +internal sealed partial class DeleteBookmarkCommand : InvokableCommand +{ + private readonly BookmarkData _bookmark; + private readonly IBookmarksManager _bookmarksManager; + + public DeleteBookmarkCommand(BookmarkData bookmark, IBookmarksManager bookmarksManager) + { + ArgumentNullException.ThrowIfNull(bookmark); + ArgumentNullException.ThrowIfNull(bookmarksManager); + + _bookmark = bookmark; + _bookmarksManager = bookmarksManager; + Name = Resources.bookmarks_delete_name; + Icon = Icons.DeleteIcon; + } + + public override CommandResult Invoke() + { + _bookmarksManager.Remove(_bookmark.Id); + return CommandResult.GoHome(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs new file mode 100644 index 0000000000..a5b3c460ba --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs @@ -0,0 +1,109 @@ +// 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.Globalization; +using System.Text; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Commands; + +internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokableCommand, IDisposable +{ + private static readonly CompositeFormat FailedToOpenMessageFormat = CompositeFormat.Parse(Resources.bookmark_toast_failed_open_text!); + + private readonly BookmarkData _bookmarkData; + private readonly Dictionary? _placeholders; + private readonly IBookmarkResolver _bookmarkResolver; + private readonly SupersedingAsyncValueGate _iconReloadGate; + private readonly Classification _classification; + + private IIconInfo? _icon; + + public IIconInfo Icon => _icon ?? Icons.Reloading; + + public string Name { get; } + + public string Id { get; } + + public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, Dictionary? placeholders = null) + { + ArgumentNullException.ThrowIfNull(bookmarkData); + ArgumentNullException.ThrowIfNull(classification); + + _bookmarkData = bookmarkData; + _classification = classification; + _placeholders = placeholders; + _bookmarkResolver = bookmarkResolver; + + Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id); + Name = Resources.bookmarks_command_name_open; + + _iconReloadGate = new( + async ct => await iconLocator.GetIconForPath(_classification, ct), + icon => + { + _icon = icon; + OnPropertyChanged(nameof(Icon)); + }); + + RequestIconReloadAsync(); + } + + private void RequestIconReloadAsync() + { + _icon = null; + OnPropertyChanged(nameof(Icon)); + _ = _iconReloadGate.ExecuteAsync(); + } + + public ICommandResult Invoke(object sender) + { + var bookmarkAddress = ReplacePlaceholders(_bookmarkData.Bookmark); + var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress); + + var success = CommandLauncher.Launch(classification); + + return success + ? CommandResult.Dismiss() + : CommandResult.ShowToast(new ToastArgs + { + Message = !string.IsNullOrWhiteSpace(_bookmarkData.Name) + ? string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, _bookmarkData.Name + ": " + bookmarkAddress) + : string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, bookmarkAddress), + Result = CommandResult.KeepOpen(), + }); + } + + private string ReplacePlaceholders(string input) + { + var result = input; + if (_placeholders?.Count > 0) + { + foreach (var (key, value) in _placeholders) + { + var placeholderString = $"{{{key}}}"; + + var encodedValue = value; + if (_classification.Kind is CommandKind.Protocol or CommandKind.WebUrl) + { + encodedValue = Uri.EscapeDataString(value); + } + + result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase); + } + } + + return result; + } + + public void Dispose() + { + _iconReloadGate.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs new file mode 100644 index 0000000000..c391ea8586 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs @@ -0,0 +1,8 @@ +// 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. + +global using System; +global using System.Collections.Generic; +global using Microsoft.CmdPal.Ext.Bookmarks.Properties; +global using Microsoft.CommandPalette.Extensions.Toolkit; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs new file mode 100644 index 0000000000..a9b1cb4837 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +public sealed record Classification( + CommandKind Kind, + string Input, + string Target, + string Arguments, + LaunchMethod Launch, + string? WorkingDirectory, + bool IsPlaceholder, + string? FileSystemTarget = null, + string? DisplayName = null) +{ + public static Classification Unknown(string rawInput) => + new(CommandKind.Unknown, rawInput, rawInput, string.Empty, LaunchMethod.ShellExecute, string.Empty, false, null, null); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs new file mode 100644 index 0000000000..57d82b6e30 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static class CommandIds +{ + /// + /// Returns id of a command associated with a bookmark item. This id is for a command that launches the bookmark - regardless of whether + /// the bookmark type of if it is a placeholder bookmark or not. + /// + /// Bookmark ID + public static string GetLaunchBookmarkItemId(Guid id) => "Bookmarks.Launch." + id; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs new file mode 100644 index 0000000000..9c9f0f053d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// +/// Classifies a command or bookmark target type. +/// +public enum CommandKind +{ + /// + /// Unknown or unsupported target. + /// + Unknown = 0, + + /// + /// HTTP/HTTPS URL. + /// + WebUrl, + + /// + /// Any non-file URI scheme (e.g., mailto:, ms-settings:, wt:, myapp:). + /// + Protocol, + + /// + /// Application User Model ID (e.g., shell:AppsFolder\AUMID or pkgfamily!app). + /// + Aumid, + + /// + /// Existing folder path. + /// + Directory, + + /// + /// Existing executable file (e.g., .exe, .bat, .cmd). + /// + FileExecutable, + + /// + /// Existing document file. + /// + FileDocument, + + /// + /// Windows shortcut file (*.lnk). + /// + Shortcut, + + /// + /// Internet shortcut file (*.url). + /// + InternetShortcut, + + /// + /// Bare command resolved via PATH/PATHEXT (e.g., "wt", "git"). + /// + PathCommand, + + /// + /// Shell item not matching other types (e.g., Control Panel item, purely virtual directory). + /// + VirtualShellItem, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs new file mode 100644 index 0000000000..742e272f4b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs @@ -0,0 +1,98 @@ +// 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.Runtime.InteropServices; +using ManagedCommon; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static class CommandLauncher +{ + /// + /// Launches the classified item. + /// + /// Classification produced by CommandClassifier. + /// Optional: force elevation if possible. + public static bool Launch(Classification classification, bool runAsAdmin = false) + { + switch (classification.Launch) + { + case LaunchMethod.ExplorerOpen: + // Folders and shell: URIs are best handled by explorer.exe + // You can notice the difference with Recycle Bin for example: + // - "explorer ::{645FF040-5081-101B-9F08-00AA002F954E}" + // - "::{645FF040-5081-101B-9F08-00AA002F954E}" + return ShellHelpers.OpenInShell("explorer.exe", classification.Target); + + case LaunchMethod.ActivateAppId: + return ActivateAppId(classification.Target, classification.Arguments); + + case LaunchMethod.ShellExecute: + default: + return ShellHelpers.OpenInShell(classification.Target, classification.Arguments, classification.WorkingDirectory, runAsAdmin ? ShellHelpers.ShellRunAsType.Administrator : ShellHelpers.ShellRunAsType.None); + } + } + + private static bool ActivateAppId(string aumidOrAppsFolder, string? arguments) + { + const string shellAppsFolder = "shell:AppsFolder\\"; + try + { + if (aumidOrAppsFolder.StartsWith(shellAppsFolder, StringComparison.OrdinalIgnoreCase)) + { + aumidOrAppsFolder = aumidOrAppsFolder[shellAppsFolder.Length..]; + } + + ApplicationActivationManager.ActivateApplication(aumidOrAppsFolder, arguments, 0, out _); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Can't activate AUMID using app store '{aumidOrAppsFolder}'", ex); + } + + try + { + ShellHelpers.OpenInShell(shellAppsFolder + aumidOrAppsFolder, arguments); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Can't activate AUMID using shell '{aumidOrAppsFolder}'", ex); + } + + return false; + } + + private static class ApplicationActivationManager + { + public static void ActivateApplication(string aumid, string? args, int options, out uint pid) + { + var mgr = (IApplicationActivationManager)new _ApplicationActivationManager(); + var hr = mgr.ActivateApplication(aumid, args ?? string.Empty, options, out pid); + if (hr < 0) + { + throw new Win32Exception(hr); + } + } + + [ComImport] + [Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Private class")] + private class _ApplicationActivationManager; + + [ComImport] + [Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IApplicationActivationManager + { + int ActivateApplication( + [MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, + [MarshalAs(UnmanagedType.LPWStr)] string arguments, + int options, + out uint processId); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs new file mode 100644 index 0000000000..1d7cd1aca2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs @@ -0,0 +1,294 @@ +// 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.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// +/// Provides helper methods for parsing command lines and expanding paths. +/// +/// +/// Warning: This code handles parsing specifically for Bookmarks, and is NOT a general-purpose command line parser. +/// In some cases it mimics system rules (e.g. CreateProcess, CommandLineToArgvW) but in other cases it uses, but it can also +/// bend the rules to be more forgiving. +/// +internal static partial class CommandLineHelper +{ + private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + + public static string[] SplitCommandLine(string commandLine) + { + ArgumentNullException.ThrowIfNull(commandLine); + + var argv = NativeMethods.CommandLineToArgvW(commandLine, out var argc); + if (argv == IntPtr.Zero) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + var result = new string[argc]; + for (var i = 0; i < argc; i++) + { + var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size); + result[i] = Marshal.PtrToStringUni(p)!; + } + + return result; + } + finally + { + NativeMethods.LocalFree(argv); + } + } + + /// + /// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules + /// of CommandLineToArgvW. + /// + /// + /// This is a mental support for SplitLongestHeadBeforeQuotedArg. + /// + /// Rules: + /// - If the input starts with any whitespace, Head is an empty string (per CommandLineToArgvW behavior for first segment, handles by CreateProcess rules). + /// - Otherwise, Head uses the CreateProcess "program name" rule: + /// - If the first char is a quote, Head is everything up to the next quote (backslashes do NOT escape it). + /// - Else, Head is the run up to the first whitespace. + /// - Tail starts at the first non-whitespace character after Head (or is empty if nothing remains). + /// No normalization is performed; returned slices preserve the original text (no un/escaping). + /// + public static (string Head, string Tail) SplitHeadAndArgs(string input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) + { + return (string.Empty, string.Empty); + } + + var s = input.AsSpan(); + var n = s.Length; + var i = 0; + + // Leading whitespace -> empty argv[0] + if (char.IsWhiteSpace(s[0])) + { + while (i < n && char.IsWhiteSpace(s[i])) + { + i++; + } + + var tailAfterWs = i < n ? input[i..] : string.Empty; + return (string.Empty, tailAfterWs); + } + + string head; + if (s[i] == '"') + { + // Quoted program name: everything up to the next unescaped quote (CreateProcess rule: slashes don't escape here) + i++; + var start = i; + while (i < n && s[i] != '"') + { + i++; + } + + head = input.Substring(start, i - start); + if (i < n && s[i] == '"') + { + i++; // consume closing quote + } + } + else + { + // Unquoted program name: read to next whitespace + var start = i; + while (i < n && !char.IsWhiteSpace(s[i])) + { + i++; + } + + head = input.Substring(start, i - start); + } + + // Skip inter-argument whitespace; tail begins at the next non-ws char (or is empty) + while (i < n && char.IsWhiteSpace(s[i])) + { + i++; + } + + var tail = i < n ? input[i..] : string.Empty; + + return (head, tail); + } + + /// + /// Returns the longest possible head (may include spaces) and the tail that starts at the + /// first *quoted argument*. + /// + /// Definition of "quoted argument start": + /// - A token boundary (start-of-line or preceded by whitespace), + /// - followed by zero or more backslashes, + /// - followed by a double-quote ("), + /// - where the number of immediately preceding backslashes is EVEN (so the quote toggles quoting). + /// + /// Notes: + /// - Quotes appearing mid-token (e.g., C:\Some\"Path\file.txt) do NOT stop the head. + /// - Trailing spaces before the quoted arg are not included in Head; Tail begins at that quote. + /// - Leading whitespace before the first token is ignored (Head starts from first non-ws). + /// Examples: + /// C:\app exe -p "1" -q -> Head: "C:\app exe -p", Tail: "\"1\" -q" + /// "\\server\share\" with args -> Head: "", Tail: "\"\\\\server\\share\\\" with args" + /// C:\Some\"Path\file.txt -> Head: "C:\\Some\\\"Path\\file.txt", Tail: "" + /// + public static (string Head, string Tail) SplitLongestHeadBeforeQuotedArg(string input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) + { + return (string.Empty, string.Empty); + } + + var s = input.AsSpan(); + var n = s.Length; + + // Start at first non-whitespace (we don't treat leading ws as part of Head here) + var start = 0; + while (start < n && char.IsWhiteSpace(s[start])) + { + start++; + } + + if (start >= n) + { + return (string.Empty, string.Empty); + } + + // Scan for a quote that OPENS a quoted argument at a token boundary. + for (var i = start; i < n; i++) + { + if (s[i] != '"') + { + continue; + } + + // Count immediate backslashes before this quote + int j = i - 1, backslashes = 0; + while (j >= start && s[j] == '\\') + { + backslashes++; + j--; + } + + // The quote is at a token boundary if the char before the backslashes is start-of-line or whitespace. + var atTokenBoundary = j < start || char.IsWhiteSpace(s[j]); + + // Even number of backslashes -> this quote toggles quoting (opens if at boundary). + if (atTokenBoundary && (backslashes % 2 == 0)) + { + // Trim trailing spaces off Head so Tail starts exactly at the opening quote + var headEnd = i; + while (headEnd > start && char.IsWhiteSpace(s[headEnd - 1])) + { + headEnd--; + } + + var head = input[start..headEnd]; + var tail = input[headEnd..]; // starts at the opening quote + return (head, tail.Trim()); + } + } + + // No quoted-arg start found: entire remainder (trimmed right) is the Head + var wholeHead = input[start..].TrimEnd(); + return (wholeHead, string.Empty); + } + + /// + /// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers. + /// + internal static bool ExpandPathToPhysicalFile(string input, bool expandShell, out string full) + { + if (string.IsNullOrEmpty(input)) + { + full = string.Empty; + return false; + } + + var expanded = Environment.ExpandEnvironmentVariables(input); + + var firstSegment = GetFirstPathSegment(expanded); + if (expandShell && HasShellPrefix(firstSegment) && TryExpandShellMoniker(expanded, out var shellExpanded)) + { + expanded = shellExpanded; + } + else if (firstSegment is "~" or "." or "..") + { + expanded = ExpandUserRelative(firstSegment, expanded); + } + + if (Path.Exists(expanded)) + { + full = Path.GetFullPath(expanded); + return true; + } + + full = expanded; // return the attempted expansion even if it doesn't exist + return false; + } + + private static bool TryExpandShellMoniker(string input, out string expanded) + { + var separatorIndex = input.IndexOfAny(PathSeparators); + var shellFolder = separatorIndex > 0 ? input[..separatorIndex] : input; + var relativePath = separatorIndex > 0 ? input[(separatorIndex + 1)..] : string.Empty; + + if (ShellNames.TryGetFileSystemPath(shellFolder, out var fsPath)) + { + expanded = Path.GetFullPath(Path.Combine(fsPath, relativePath)); + return true; + } + + expanded = input; + return false; + } + + private static string ExpandUserRelative(string firstSegment, string input) + { + // Treat relative paths as relative to the user home directory. + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (firstSegment == "~") + { + // Remove "~" (+ optional following separator) before combining. + var skip = 1; + if (input.Length > 1 && IsSeparator(input[1])) + { + skip++; + } + + input = input[skip..]; + } + + return Path.GetFullPath(Path.Combine(homeDirectory, input)); + } + + private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + + private static string GetFirstPathSegment(string input) + { + var separatorIndex = input.IndexOfAny(PathSeparators); + return separatorIndex > 0 ? input[..separatorIndex] : input; + } + + internal static bool HasShellPrefix(string input) + { + return input.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || input.StartsWith("::", StringComparison.Ordinal); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs new file mode 100644 index 0000000000..eaedb88aea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +public enum LaunchMethod +{ + ShellExecute, // UseShellExecute = true (Explorer/associations/protocols) + ExplorerOpen, // explorer.exe + ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app) +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..9cba2aba74 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs @@ -0,0 +1,47 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static partial class NativeMethods +{ + [LibraryImport("shell32.dll", EntryPoint = "SHParseDisplayName", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int SHParseDisplayName( + string pszName, + nint pbc, + out nint ppidl, + uint sfgaoIn, + nint psfgaoOut); + + [LibraryImport("shell32.dll", EntryPoint = "SHGetNameFromIDList", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int SHGetNameFromIDList( + nint pidl, + SIGDN sigdnName, + out nint ppszName); + + [LibraryImport("ole32.dll")] + internal static partial void CoTaskMemFree(nint pv); + + [LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs); + + [LibraryImport("kernel32.dll")] + internal static partial IntPtr LocalFree(IntPtr hMem); + + internal enum SIGDN : uint + { + NORMALDISPLAY = 0x00000000, + DESKTOPABSOLUTEPARSING = 0x80028000, + DESKTOPABSOLUTEEDITING = 0x8004C000, + FILESYSPATH = 0x80058000, + URL = 0x80068000, + PARENTRELATIVE = 0x80080001, + PARENTRELATIVEFORADDRESSBAR = 0x8007C001, + PARENTRELATIVEPARSING = 0x80018001, + PARENTRELATIVEEDITING = 0x80031001, + PARENTRELATIVEFORUI = 0x80094001, + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs new file mode 100644 index 0000000000..d290deff47 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// +/// Helpers for getting user-friendly shell names and paths. +/// +internal static class ShellNames +{ + /// + /// Tries to get a localized friendly name (e.g. "This PC", "Downloads") for a shell path like: + /// - "shell:Downloads" + /// - "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" + /// - "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" + /// + public static bool TryGetFriendlyName(string shellPath, [NotNullWhen(true)] out string? displayName) + { + displayName = null; + + // Normalize a bare GUID to the "::" moniker if someone passes only "{GUID}" + if (shellPath.Length > 0 && shellPath[0] == '{' && shellPath[^1] == '}') + { + shellPath = "::" + shellPath; + } + + nint pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0); + if (hr != 0 || pidl == 0) + { + return false; + } + + // Ask for the human-friendly localized name + nint psz; + hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.NORMALDISPLAY, out psz); + if (hr != 0 || psz == 0) + { + return false; + } + + try + { + displayName = Marshal.PtrToStringUni(psz); + return !string.IsNullOrWhiteSpace(displayName); + } + finally + { + NativeMethods.CoTaskMemFree(psz); + } + } + finally + { + if (pidl != 0) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } + + /// + /// Optionally, also try to obtain a filesystem path (if the item represents one). + /// Returns false for purely virtual items like "This PC". + /// + public static bool TryGetFileSystemPath(string shellPath, [NotNullWhen(true)] out string? fileSystemPath) + { + fileSystemPath = null; + + nint pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0); + if (hr != 0 || pidl == 0) + { + return false; + } + + nint psz; + hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.FILESYSPATH, out psz); + if (hr != 0 || psz == 0) + { + return false; + } + + try + { + fileSystemPath = Marshal.PtrToStringUni(psz); + return !string.IsNullOrWhiteSpace(fileSystemPath); + } + finally + { + NativeMethods.CoTaskMemFree(psz); + } + } + finally + { + if (pidl != 0) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs new file mode 100644 index 0000000000..14befe9a68 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static class UriHelper +{ + /// + /// Tries to split a URI string into scheme and remainder. + /// Scheme must be valid per RFC 3986 and followed by ':'. + /// + public static bool TryGetScheme(ReadOnlySpan input, out string scheme, out string remainder) + { + // https://datatracker.ietf.org/doc/html/rfc3986#page-17 + scheme = string.Empty; + remainder = string.Empty; + + if (input.Length < 2) + { + return false; // must have at least "a:" + } + + // Must contain ':' delimiter + var colonIndex = input.IndexOf(':'); + if (colonIndex <= 0) + { + return false; // no colon or colon at start + } + + // First char must be a letter + var first = input[0]; + if (!char.IsLetter(first)) + { + return false; + } + + // Validate scheme part + for (var i = 1; i < colonIndex; i++) + { + var c = input[i]; + if (!(char.IsLetterOrDigit(c) || c == '+' || c == '-' || c == '.')) + { + return false; + } + } + + // Extract scheme and remainder + scheme = input[..colonIndex].ToString(); + remainder = colonIndex + 1 < input.Length ? input[(colonIndex + 1)..].ToString() : string.Empty; + return true; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs new file mode 100644 index 0000000000..74ab025d0f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs @@ -0,0 +1,24 @@ +// 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.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal interface IBookmarksManager +{ + event Action? BookmarkAdded; + + event Action? BookmarkUpdated; + + event Action? BookmarkRemoved; + + IReadOnlyCollection Bookmarks { get; } + + BookmarkData Add(string name, string bookmark); + + bool Remove(Guid id); + + BookmarkData? Update(Guid id, string name, string bookmark); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs index 6f8fd8b05e..6e7d955606 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs @@ -2,17 +2,41 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.CommandPalette.Extensions.Toolkit; - namespace Microsoft.CmdPal.Ext.Bookmarks; -internal sealed class Icons +internal static class Icons { - internal static IconInfo BookmarkIcon => IconHelpers.FromRelativePath("Assets\\Bookmark.svg"); + internal static IconInfo BookmarkIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmark.svg"); - internal static IconInfo DeleteIcon { get; private set; } = new("\uE74D"); // Delete + internal static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete - internal static IconInfo EditIcon { get; private set; } = new("\uE70F"); // Edit + internal static IconInfo EditIcon { get; } = new("\uE70F"); // Edit - internal static IconInfo PinIcon { get; private set; } = new IconInfo("\uE718"); // Pin + internal static IconInfo PinIcon { get; } = new IconInfo("\uE718"); // Pin + + internal static IconInfo Reloading { get; } = new IconInfo("\uF16A"); // ProgressRing + + internal static IconInfo CopyPath { get; } = new IconInfo("\uE8C8"); // Copy + + internal static class BookmarkTypes + { + internal static IconInfo WebUrl { get; } = new("\uE774"); // Globe + + internal static IconInfo FilePath { get; } = new("\uE8A5"); // OpenFile + + internal static IconInfo FolderPath { get; } = new("\uE8B7"); // OpenFolder + + internal static IconInfo Application { get; } = new("\uE737"); // Favicon (~looks like empty window) + + internal static IconInfo Command { get; } = new("\uE756"); // CommandPrompt + + internal static IconInfo Unknown { get; } = new("\uE71B"); // Link + + internal static IconInfo Game { get; } = new("\uE7FC"); // Game controller + } + + private static IconInfo DualColorFromRelativePath(string name) + { + return IconHelpers.FromRelativePaths($"Assets\\Icons\\{name}.light.svg", $"Assets\\Icons\\{name}.dark.svg"); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs new file mode 100644 index 0000000000..18d818b727 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs @@ -0,0 +1,20 @@ +// 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.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal static class KeyChords +{ + internal static KeyChord CopyPath => WellKnownKeyChords.CopyFilePath; + + internal static KeyChord OpenFileLocation => WellKnownKeyChords.OpenFileLocation; + + internal static KeyChord OpenInConsole => WellKnownKeyChords.OpenInConsole; + + internal static KeyChord DeleteBookmark => KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj index 40c3cca9f2..f47b5e216f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj @@ -10,13 +10,15 @@ Microsoft.CmdPal.Ext.Bookmarks.pri - - - + + + PreserveNewest + + @@ -26,14 +28,6 @@ - - - PreserveNewest - - - PreserveNewest - - Resources.Designer.cs @@ -41,4 +35,7 @@ + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs deleted file mode 100644 index 1ea5016cf4..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class OpenInTerminalCommand : InvokableCommand -{ - private readonly string _folder; - - public OpenInTerminalCommand(string folder) - { - Name = Resources.bookmarks_open_in_terminal_name; - _folder = folder; - } - - public override ICommandResult Invoke() - { - try - { - // Start Windows Terminal with the specified folder - var startInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "wt.exe", - Arguments = $"-d \"{_folder}\"", - UseShellExecute = true, - }; - System.Diagnostics.Process.Start(startInfo); - } - catch (Exception ex) - { - Logger.LogError(ex.Message); - } - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs similarity index 78% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs index 93fc6d8d01..6931064a90 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs @@ -4,38 +4,28 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Windows.Foundation; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; internal sealed partial class AddBookmarkForm : FormContent { - internal event TypedEventHandler? AddedCommand; - private readonly BookmarkData? _bookmark; + internal event TypedEventHandler? AddedCommand; + public AddBookmarkForm(BookmarkData? bookmark) { _bookmark = bookmark; - var name = _bookmark?.Name ?? string.Empty; - var url = _bookmark?.Bookmark ?? string.Empty; + var name = bookmark?.Name ?? string.Empty; + var url = bookmark?.Bookmark ?? string.Empty; TemplateJson = $$""" { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.5", "body": [ - { - "type": "Input.Text", - "style": "text", - "id": "name", - "label": "{{Resources.bookmarks_form_name_label}}", - "value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}}, - "isRequired": true, - "errorMessage": "{{Resources.bookmarks_form_name_required}}" - }, { "type": "Input.Text", "style": "text", @@ -44,6 +34,15 @@ internal sealed partial class AddBookmarkForm : FormContent "label": "{{Resources.bookmarks_form_bookmark_label}}", "isRequired": true, "errorMessage": "{{Resources.bookmarks_form_bookmark_required}}" + }, + { + "type": "Input.Text", + "style": "text", + "id": "name", + "label": "{{Resources.bookmarks_form_name_label}}", + "value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}}, + "isRequired": false, + "errorMessage": "{{Resources.bookmarks_form_name_required}}" } ], "actions": [ @@ -71,13 +70,7 @@ internal sealed partial class AddBookmarkForm : FormContent // get the name and url out of the values var formName = formInput["name"] ?? string.Empty; var formBookmark = formInput["bookmark"] ?? string.Empty; - var hasPlaceholder = formBookmark.ToString().Contains('{') && formBookmark.ToString().Contains('}'); - - var updated = _bookmark ?? new BookmarkData(); - updated.Name = formName.ToString(); - updated.Bookmark = formBookmark.ToString(); - - AddedCommand?.Invoke(this, updated); + AddedCommand?.Invoke(this, new BookmarkData(formName.ToString(), formBookmark.ToString()) { Id = _bookmark?.Id ?? Guid.Empty }); return CommandResult.GoHome(); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs similarity index 68% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs index d74b942990..927044e77c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs @@ -2,33 +2,33 @@ // 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.Bookmarks.Properties; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; internal sealed partial class AddBookmarkPage : ContentPage { - private readonly AddBookmarkForm _addBookmark; - internal event TypedEventHandler? AddedCommand { - add => _addBookmark.AddedCommand += value; - remove => _addBookmark.AddedCommand -= value; + add => _addBookmarkForm.AddedCommand += value; + remove => _addBookmarkForm.AddedCommand -= value; } - public override IContent[] GetContent() => [_addBookmark]; + private readonly AddBookmarkForm _addBookmarkForm; public AddBookmarkPage(BookmarkData? bookmark) { var name = bookmark?.Name ?? string.Empty; var url = bookmark?.Bookmark ?? string.Empty; + Icon = Icons.BookmarkIcon; var isAdd = string.IsNullOrEmpty(name) && string.IsNullOrEmpty(url); Title = isAdd ? Resources.bookmarks_add_title : Resources.bookmarks_edit_name; Name = isAdd ? Resources.bookmarks_add_name : Resources.bookmarks_edit_name; - _addBookmark = new(bookmark); + _addBookmarkForm = new AddBookmarkForm(bookmark); } + + public override IContent[] GetContent() => [_addBookmarkForm]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs new file mode 100644 index 0000000000..fe1e56c66e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs @@ -0,0 +1,304 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Core.Common.Commands; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Commands; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CmdPal.Ext.Indexer; +using Microsoft.CommandPalette.Extensions; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkListItem : ListItem, IDisposable +{ + private readonly IBookmarksManager _bookmarksManager; + private readonly IBookmarkResolver _commandResolver; + private readonly IBookmarkIconLocator _iconLocator; + private readonly IPlaceholderParser _placeholderParser; + private readonly SupersedingAsyncValueGate _classificationGate; + private readonly TaskCompletionSource _initializationTcs = new(); + + private BookmarkData _bookmark; + + public Task IsInitialized => _initializationTcs.Task; + + public string BookmarkAddress => _bookmark.Bookmark; + + public string BookmarkTitle => _bookmark.Name; + + public Guid BookmarkId => _bookmark.Id; + + public BookmarkListItem(BookmarkData bookmark, IBookmarksManager bookmarksManager, IBookmarkResolver commandResolver, IBookmarkIconLocator iconLocator, IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(bookmark); + ArgumentNullException.ThrowIfNull(bookmarksManager); + ArgumentNullException.ThrowIfNull(commandResolver); + + _bookmark = bookmark; + _bookmarksManager = bookmarksManager; + _bookmarksManager.BookmarkUpdated += BookmarksManagerOnBookmarkUpdated; + _commandResolver = commandResolver; + _iconLocator = iconLocator; + _placeholderParser = placeholderParser; + _classificationGate = new SupersedingAsyncValueGate(ClassifyAsync, ApplyClassificationResult); + _ = _classificationGate.ExecuteAsync(); + } + + private void BookmarksManagerOnBookmarkUpdated(BookmarkData original, BookmarkData @new) + { + if (original.Id == _bookmark.Id) + { + Update(@new); + } + } + + public void Dispose() + { + _classificationGate.Dispose(); + var existing = Command; + if (existing != null) + { + existing.PropChanged -= CommandPropertyChanged; + } + } + + private void Update(BookmarkData data) + { + ArgumentNullException.ThrowIfNull(data); + + try + { + _bookmark = data; + OnPropertyChanged(nameof(BookmarkTitle)); + OnPropertyChanged(nameof(BookmarkAddress)); + + Subtitle = Resources.bookmarks_item_refreshing; + _ = _classificationGate.ExecuteAsync(); + } + catch (Exception ex) + { + Logger.LogError("Failed to update bookmark", ex); + } + } + + private async Task ClassifyAsync(CancellationToken ct) + { + TypedEventHandler bookmarkSavedHandler = BookmarkSaved; + List contextMenu = []; + + var classification = (await _commandResolver.TryClassifyAsync(_bookmark.Bookmark, ct)).Result; + + var title = BuildTitle(_bookmark, classification); + var subtitle = BuildSubtitle(_bookmark, classification); + + ICommand command = classification.IsPlaceholder + ? new BookmarkPlaceholderPage(_bookmark, _iconLocator, _commandResolver, _placeholderParser) + : new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver); + + BuildSpecificContextMenuItems(classification, contextMenu); + AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu); + + return new BookmarkListItemReclassifyResult( + command, + title, + subtitle, + contextMenu.ToArray()); + } + + private void ApplyClassificationResult(BookmarkListItemReclassifyResult classificationResult) + { + var existing = Command; + if (existing != null) + { + existing.PropChanged -= CommandPropertyChanged; + } + + classificationResult.Command.PropChanged += CommandPropertyChanged; + Command = classificationResult.Command; + OnPropertyChanged(nameof(Icon)); + Title = classificationResult.Title; + Subtitle = classificationResult.Subtitle; + MoreCommands = classificationResult.MoreCommands; + + _initializationTcs.TrySetResult(); + } + + private void CommandPropertyChanged(object sender, IPropChangedEventArgs args) => + OnPropertyChanged(args.PropertyName); + + private static void BuildSpecificContextMenuItems(Classification classification, List contextMenu) + { + // TODO: unify across all built-in extensions + var bookmarkTargetType = classification.Kind; + + // TODO: add "Run as administrator" for executables/shortcuts + if (!classification.IsPlaceholder) + { + if (bookmarkTargetType == CommandKind.FileDocument && File.Exists(classification.Target)) + { + contextMenu.Add(new CommandContextItem(new OpenWithCommand(classification.Input))); + } + } + + string? directoryPath = null; + var targetPath = classification.Target; + switch (bookmarkTargetType) + { + case CommandKind.Directory: + directoryPath = targetPath; + contextMenu.Add(new CommandContextItem(new DirectoryPage(directoryPath))); // Browse + break; + case CommandKind.FileExecutable: + case CommandKind.FileDocument: + case CommandKind.Shortcut: + case CommandKind.InternetShortcut: + try + { + directoryPath = Path.GetDirectoryName(targetPath); + } + catch + { + // ignore any path parsing errors + } + + break; + case CommandKind.WebUrl: + case CommandKind.Protocol: + case CommandKind.Aumid: + case CommandKind.PathCommand: + case CommandKind.Unknown: + default: + break; + } + + // Add "Copy Path" or "Copy Address" command + if (!string.IsNullOrWhiteSpace(classification.Input)) + { + var copyCommand = new CopyPathCommand(targetPath) + { + Name = bookmarkTargetType is CommandKind.WebUrl or CommandKind.Protocol + ? Resources.bookmarks_copy_address_name + : Resources.bookmarks_copy_path_name, + Icon = Icons.CopyPath, + }; + + contextMenu.Add(new CommandContextItem(copyCommand) { RequestedShortcut = KeyChords.CopyPath }); + } + + // Add "Open in Console" and "Show in Folder" commands if we have a valid directory path + if (!string.IsNullOrWhiteSpace(directoryPath) && Directory.Exists(directoryPath)) + { + contextMenu.Add(new CommandContextItem(new ShowFileInFolderCommand(targetPath)) { RequestedShortcut = KeyChords.OpenFileLocation }); + contextMenu.Add(new CommandContextItem(OpenInConsoleCommand.FromDirectory(directoryPath)) { RequestedShortcut = KeyChords.OpenInConsole }); + } + + if (!string.IsNullOrWhiteSpace(targetPath) && (File.Exists(targetPath) || Directory.Exists(targetPath))) + { + contextMenu.Add(new CommandContextItem(new OpenPropertiesCommand(targetPath))); + } + } + + private static string BuildSubtitle(BookmarkData bookmark, Classification classification) + { + var subtitle = BuildSubtitleCore(bookmark, classification); +#if DEBUG + subtitle = $" ({classification.Kind}) • " + subtitle; +#endif + return subtitle; + } + + private static string BuildSubtitleCore(BookmarkData bookmark, Classification classification) + { + if (classification.Kind == CommandKind.Unknown) + { + return bookmark.Bookmark; + } + + if (classification.Kind is CommandKind.VirtualShellItem && + ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName)) + { + return friendlyName; + } + + if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) && + !string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + + return bookmark.Bookmark; + } + + private static string BuildTitle(BookmarkData bookmark, Classification classification) + { + if (!string.IsNullOrWhiteSpace(bookmark.Name)) + { + return bookmark.Name; + } + + if (classification.Kind is CommandKind.Unknown or CommandKind.WebUrl or CommandKind.Protocol) + { + return bookmark.Bookmark; + } + + if (ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName)) + { + return friendlyName; + } + + if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) && + !string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + + return bookmark.Bookmark; + } + + private static void AddCommonContextMenuItems( + BookmarkData bookmark, + IBookmarksManager bookmarksManager, + TypedEventHandler bookmarkSavedHandler, + List contextMenu) + { + contextMenu.Add(new Separator()); + + var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon }; + edit.AddedCommand += bookmarkSavedHandler; + contextMenu.Add(new CommandContextItem(edit)); + + var confirmableCommand = new ConfirmableCommand + { + Command = new DeleteBookmarkCommand(bookmark, bookmarksManager), + ConfirmationTitle = Resources.bookmarks_delete_prompt_title!, + ConfirmationMessage = Resources.bookmarks_delete_prompt_message!, + Name = Resources.bookmarks_delete_name, + Icon = Icons.DeleteIcon, + }; + var delete = new CommandContextItem(confirmableCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteBookmark }; + contextMenu.Add(delete); + } + + private void BookmarkSaved(object sender, BookmarkData args) + { + ExtensionHost.LogMessage($"Saving bookmark ({args.Name},{args.Bookmark})"); + _bookmarksManager.Update(args.Id, args.Name, args.Bookmark); + } + + private readonly record struct BookmarkListItemReclassifyResult( + ICommand Command, + string Title, + string Subtitle, + IContextItem[] MoreCommands + ); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs new file mode 100644 index 0000000000..8064474fab --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs @@ -0,0 +1,119 @@ +// 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.Globalization; +using System.Linq; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkPlaceholderForm : FormContent +{ + private static readonly CompositeFormat ErrorMessage = CompositeFormat.Parse(Resources.bookmarks_required_placeholder); + + private readonly BookmarkData _bookmarkData; + private readonly IBookmarkResolver _commandResolver; + + public BookmarkPlaceholderForm(BookmarkData data, IBookmarkResolver commandResolver, IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(commandResolver); + + _bookmarkData = data; + _commandResolver = commandResolver; + placeholderParser.ParsePlaceholders(data.Bookmark, out _, out var placeholders); + var inputs = placeholders.Distinct(PlaceholderInfoNameEqualityComparer.Instance).Select(placeholder => + { + var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, placeholder.Name); + return $$""" + { + "type": "Input.Text", + "style": "text", + "id": "{{placeholder.Name}}", + "label": "{{placeholder.Name}}", + "isRequired": true, + "errorMessage": "{{errorMessage}}" + } + """; + }).ToList(); + + var allInputs = string.Join(",", inputs); + + TemplateJson = $$""" + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": "{{_bookmarkData.Name}}" + }, + {{allInputs}} + ], + "actions": [ + { + "type": "Action.Submit", + "title": "{{Resources.bookmarks_form_open}}", + "data": { + "placeholder": "placeholder" + } + } + ] + } + """; + } + + public override CommandResult SubmitForm(string payload) + { + // parse the submitted JSON and then open the link + var formInput = JsonNode.Parse(payload); + var formObject = formInput?.AsObject(); + if (formObject is null) + { + return CommandResult.GoHome(); + } + + // we need to classify this twice: + // first we need to know if the original bookmark is a URL or protocol, because that determines how we encode the placeholders + // then we need to classify the final target to be sure the classification didn't change by adding the placeholders + var placeholderClassification = _commandResolver.ClassifyOrUnknown(_bookmarkData.Bookmark); + + var placeholders = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in formObject) + { + var placeholderData = value?.ToString(); + placeholders[key] = placeholderData ?? string.Empty; + } + + var target = ReplacePlaceholders(_bookmarkData.Bookmark, placeholders, placeholderClassification); + var classification = _commandResolver.ClassifyOrUnknown(target); + var success = CommandLauncher.Launch(classification); + return success ? CommandResult.Dismiss() : CommandResult.KeepOpen(); + } + + private static string ReplacePlaceholders(string input, Dictionary placeholders, Classification classification) + { + var result = input; + foreach (var (key, value) in placeholders) + { + var placeholderString = $"{{{key}}}"; + var encodedValue = value; + if (classification.Kind is CommandKind.Protocol or CommandKind.WebUrl) + { + encodedValue = Uri.EscapeDataString(value); + } + + result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase); + } + + return result; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs new file mode 100644 index 0000000000..06b23c5252 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs @@ -0,0 +1,48 @@ +// 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.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable +{ + private readonly FormContent _bookmarkPlaceholder; + private readonly SupersedingAsyncValueGate _iconReloadGate; + + public BookmarkPlaceholderPage(BookmarkData bookmarkData, IBookmarkIconLocator iconLocator, IBookmarkResolver resolver, IPlaceholderParser placeholderParser) + { + Name = Resources.bookmarks_command_name_open; + Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id); + + _bookmarkPlaceholder = new BookmarkPlaceholderForm(bookmarkData, resolver, placeholderParser); + + _iconReloadGate = new( + async ct => + { + var c = resolver.ClassifyOrUnknown(bookmarkData.Bookmark); + return await iconLocator.GetIconForPath(c, ct); + }, + icon => + { + Icon = icon as IconInfo ?? Icons.PinIcon; + }); + RequestIconReloadAsync(); + } + + public override IContent[] GetContent() => [_bookmarkPlaceholder]; + + private void RequestIconReloadAsync() + { + Icon = Icons.Reloading; + OnPropertyChanged(nameof(Icon)); + _ = _iconReloadGate.ExecuteAsync(); + } + + public void Dispose() => _iconReloadGate.Dispose(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs new file mode 100644 index 0000000000..3129e1b578 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +public sealed record BookmarkData +{ + public Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Bookmark { get; init; } + + [JsonConstructor] + [SetsRequiredMembers] + public BookmarkData(Guid id, string? name, string? bookmark) + { + Id = id == Guid.Empty ? Guid.NewGuid() : id; + Name = name ?? string.Empty; + Bookmark = bookmark ?? string.Empty; + } + + [SetsRequiredMembers] + public BookmarkData(string? name, string? bookmark) + : this(Guid.NewGuid(), name, bookmark) + { + } + + [SetsRequiredMembers] + public BookmarkData() + : this(Guid.NewGuid(), string.Empty, string.Empty) + { + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs similarity index 63% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs index 7cc82c9c02..c0eb26b7b7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs @@ -2,11 +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.Collections.Generic; using System.Text.Json; -using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; public class BookmarkJsonParser { @@ -14,32 +12,32 @@ public class BookmarkJsonParser { } - public Bookmarks ParseBookmarks(string json) + public BookmarksData ParseBookmarks(string json) { if (string.IsNullOrWhiteSpace(json)) { - return new Bookmarks(); + return new BookmarksData(); } try { - var bookmarks = JsonSerializer.Deserialize(json, BookmarkSerializationContext.Default.Bookmarks); - return bookmarks ?? new Bookmarks(); + var bookmarks = JsonSerializer.Deserialize(json, BookmarkSerializationContext.Default.BookmarksData); + return bookmarks ?? new BookmarksData(); } catch (JsonException ex) { ExtensionHost.LogMessage($"parse bookmark data failed. ex: {ex.Message}"); - return new Bookmarks(); + return new BookmarksData(); } } - public string SerializeBookmarks(Bookmarks? bookmarks) + public string SerializeBookmarks(BookmarksData? bookmarks) { if (bookmarks == null) { return string.Empty; } - return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.Bookmarks); + return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.BookmarksData); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs similarity index 84% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs index 9730bf214d..66c5c69455 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs @@ -2,19 +2,16 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Collections.Generic; using System.Text.Json.Serialization; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; [JsonSerializable(typeof(float))] [JsonSerializable(typeof(int))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(bool))] [JsonSerializable(typeof(BookmarkData))] -[JsonSerializable(typeof(Bookmarks))] +[JsonSerializable(typeof(BookmarksData))] [JsonSerializable(typeof(List), TypeInfoPropertyName = "BookmarkList")] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] -internal sealed partial class BookmarkSerializationContext : JsonSerializerContext -{ -} +internal sealed partial class BookmarkSerializationContext : JsonSerializerContext; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs similarity index 62% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs index b02eb54e0f..81d0f21578 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs @@ -2,13 +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.Collections.Generic; -using System.IO; -using System.Text.Json; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public sealed class Bookmarks +public sealed class BookmarksData { public List Data { get; set; } = []; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs similarity index 85% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs index a87859c3ce..69dd934e2c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs @@ -2,13 +2,11 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; using System.IO; -using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; -public class FileBookmarkDataSource : IBookmarkDataSource +public sealed partial class FileBookmarkDataSource : IBookmarkDataSource { private readonly string _filePath; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs similarity index 73% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs index 7ed936a1c7..890d3683ba 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; -public interface IBookmarkDataSource +internal interface IBookmarkDataSource { string GetBookmarkData(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs index 9cdf20805d..e5a65f2db3 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs @@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Failed to open {0}. + /// + public static string bookmark_toast_failed_open_text { + get { + return ResourceManager.GetString("bookmark_toast_failed_open_text", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add bookmark. /// @@ -87,6 +96,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Copy address. + /// + public static string bookmarks_copy_address_name { + get { + return ResourceManager.GetString("bookmarks_copy_address_name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy path. + /// + public static string bookmarks_copy_path_name { + get { + return ResourceManager.GetString("bookmarks_copy_path_name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete. /// @@ -96,6 +123,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to Are you sure you want to delete this bookmark?. + /// + public static string bookmarks_delete_prompt_message { + get { + return ResourceManager.GetString("bookmarks_delete_prompt_message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete bookmark?. + /// + public static string bookmarks_delete_prompt_title { + get { + return ResourceManager.GetString("bookmarks_delete_prompt_title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Delete bookmark. /// @@ -177,6 +222,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// + /// Looks up a localized string similar to (Refreshing bookmark...). + /// + public static string bookmarks_item_refreshing { + get { + return ResourceManager.GetString("bookmarks_item_refreshing", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open in Terminal. /// @@ -194,5 +248,14 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { return ResourceManager.GetString("bookmarks_required_placeholder", resourceCulture); } } + + /// + /// Looks up a localized string similar to Unpin. + /// + public static string bookmarks_unpin_name { + get { + return ResourceManager.GetString("bookmarks_unpin_name", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx index 1038055b2d..763c697f2e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx @@ -164,4 +164,25 @@ {0} is required {0} will be replaced by a parameter name provided by the user + + (Refreshing bookmark...) + + + Delete bookmark? + + + Are you sure you want to delete this bookmark? + + + Copy path + + + Copy address + + + Unpin + + + Failed to open {0} + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs new file mode 100644 index 0000000000..fd2736ebaf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs @@ -0,0 +1,547 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal sealed partial class BookmarkResolver : IBookmarkResolver +{ + private readonly IPlaceholderParser _placeholderParser; + + private const string UriSchemeShell = "shell"; + + public BookmarkResolver(IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(placeholderParser); + _placeholderParser = placeholderParser; + } + + public async Task<(bool Success, Classification Result)> TryClassifyAsync( + string? input, + CancellationToken cancellationToken = default) + { + try + { + var result = await Task.Run( + () => TryClassify(input, out var classification) + ? classification + : Classification.Unknown(input ?? string.Empty), + cancellationToken); + return (true, result); + } + catch (Exception ex) + { + Logger.LogError("Failed to classify", ex); + var result = Classification.Unknown(input ?? string.Empty); + return (false, result); + } + } + + public Classification ClassifyOrUnknown(string input) + { + return TryClassify(input, out var c) ? c : Classification.Unknown(input); + } + + private bool TryClassify(string? input, out Classification result) + { + try + { + bool success; + + if (string.IsNullOrWhiteSpace(input)) + { + result = Classification.Unknown(input ?? string.Empty); + success = false; + } + else + { + input = input.Trim(); + + // is placeholder? + var isPlaceholder = _placeholderParser.ParsePlaceholders(input, out var inputUntilFirstPlaceholder, out _); + success = ClassifyCore(input, out result, isPlaceholder, inputUntilFirstPlaceholder, _placeholderParser); + } + + return success; + } + catch (Exception ex) + { + Logger.LogError($"Failed to classify bookmark \"{input}\"", ex); + result = Classification.Unknown(input ?? string.Empty); + return false; + } + } + + private static bool ClassifyCore(string input, out Classification result, bool isPlaceholder, string inputUntilFirstPlaceholder, IPlaceholderParser placeholderParser) + { + // 1) Try URI parsing first (accepts custom schemes, e.g., shell:, ms-settings:) + // File URIs must start with "file:" to avoid confusion with local paths - which are handled below, in more sophisticated ways - + // as TryCreate would automatically add "file://" to bare paths like "C:\path\to\file.txt" which we don't want. + if (Uri.TryCreate(input, UriKind.Absolute, out var uri) + && !string.IsNullOrWhiteSpace(uri.Scheme) + && (uri.Scheme != Uri.UriSchemeFile || input.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + && uri.Scheme != UriSchemeShell) + { + // http/https → Url; any other scheme → Protocol (mailto:, ms-settings:, slack://, etc.) + var isWeb = uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + + result = new Classification( + isWeb ? CommandKind.WebUrl : CommandKind.Protocol, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, // Shell picks the right handler + null, + isPlaceholder); + + return true; + } + + // 1a) We're a placeholder and start look like a protocol scheme (e.g. "myapp:{{placeholder}}") + if (isPlaceholder && UriHelper.TryGetScheme(inputUntilFirstPlaceholder, out var scheme, out _)) + { + // single letter schemes are probably drive letters, ignore, file and shell protocols are handled elsewhere + if (scheme.Length > 1 && scheme != Uri.UriSchemeFile && scheme != UriSchemeShell) + { + var isWeb = scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + + result = new Classification( + isWeb ? CommandKind.WebUrl : CommandKind.Protocol, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, // Shell picks the right handler + null, + isPlaceholder); + return true; + } + } + + // 2) Existing file/dir or "longest plausible prefix" + // Try to grow head (only for unquoted original) to include spaces until a path exists. + + // Find longest unquoted argument string + var (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input); + if (longestUnquotedHead == string.Empty) + { + (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitHeadAndArgs(input); + } + + var (headPath, tailArgs) = ExpandToBestExistingPath(longestUnquotedHead, tailAfterLongestUnquotedHead, isPlaceholder, placeholderParser); + if (headPath is not null) + { + var args = tailArgs ?? string.Empty; + + if (Directory.Exists(headPath)) + { + result = new Classification( + CommandKind.Directory, + input, + headPath, + string.Empty, + LaunchMethod.ExplorerOpen, + headPath, + isPlaceholder); + + return true; + } + + var ext = Path.GetExtension(headPath); + if (ShellHelpers.IsExecutableExtension(ext)) + { + result = new Classification( + CommandKind.FileExecutable, + input, + headPath, + args, + LaunchMethod.ShellExecute, // direct exec; or ShellExecute if you want verb support + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + var isShellLink = ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase); + var isUrlLink = ext.Equals(".url", StringComparison.OrdinalIgnoreCase); + if (isShellLink || isUrlLink) + { + // In the future we can fetch data out of the link + result = new Classification( + isUrlLink ? CommandKind.InternetShortcut : CommandKind.Shortcut, + input, + headPath, + string.Empty, + LaunchMethod.ShellExecute, + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + result = new Classification( + CommandKind.FileDocument, + input, + headPath, + args, + LaunchMethod.ShellExecute, + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + if (TryGetAumid(longestUnquotedHead, out var aumid)) + { + result = new Classification( + CommandKind.Aumid, + longestUnquotedHead, + aumid, + tailAfterLongestUnquotedHead, + LaunchMethod.ActivateAppId, + null, + isPlaceholder); + + return true; + } + + // 3) Bare command resolution via PATH + executable ext + // At this point 'head' is our best intended command token. + var (firstHead, tail) = SplitHeadAndArgs(input); + CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head); + + // 3.1) UWP/AppX via AppsFolder/AUMID or pkgfamily!app + // Since the AUMID can be actually anything, we either take a full shell:AppsFolder\AUMID + // as entered and we try to detect packaged app ids (pkgfamily!app). + if (TryGetAumid(head, out var aumid2)) + { + result = new Classification( + CommandKind.Aumid, + head, + aumid2, + tail, + LaunchMethod.ActivateAppId, + null, + isPlaceholder); + + return true; + } + + // 3.2) It's a virtual shell item (e.g. Control Panel, Recycle Bin, This PC) + // Shell items that are backed by filesystem paths (e.g. Downloads) should be already handled above. + if (CommandLineHelper.HasShellPrefix(head)) + { + ShellNames.TryGetFriendlyName(input, out var displayName); + ShellNames.TryGetFileSystemPath(input, out var fsPath); + result = new Classification( + CommandKind.VirtualShellItem, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, + fsPath is not null && Directory.Exists(fsPath) ? fsPath : null, + isPlaceholder, + fsPath, + displayName); + return true; + } + + // 3.3) Search paths for the file name (with or without ext) + // If head is a file name with extension, we look only for that. If there's no extension + // we go and follow Windows Shell resolution rules. + if (TryResolveViaPath(head, out var resolvedFilePath)) + { + result = new Classification( + CommandKind.PathCommand, + input, + resolvedFilePath, + tail, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + + return true; + } + + // 3.4) If it looks like a path with ext but missing file, treat as document (Shell will handle assoc / error) + if (LooksPathy(head) && Path.HasExtension(head)) + { + var extension = Path.GetExtension(head); + + // if the path extension contains placeholders, we can't assume what it is so, skip it and treat it as unknown + var hasSpecificExtension = !isPlaceholder || !extension.Contains('{'); + if (hasSpecificExtension) + { + result = new Classification( + ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument, + input, + head, + tail, + LaunchMethod.ShellExecute, + HasDir(head) ? Path.GetDirectoryName(head) : null, + isPlaceholder); + + return true; + } + } + + // 4) looks like a web URL without scheme, but not like a file with extension + if (head.Contains('.', StringComparison.OrdinalIgnoreCase) && head.StartsWith("www", StringComparison.OrdinalIgnoreCase)) + { + // treat as URL, add https:// + var url = "https://" + input; + result = new Classification( + CommandKind.WebUrl, + input, + url, + string.Empty, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + return true; + } + + // 5) Fallback: let ShellExecute try the whole input + result = new Classification( + CommandKind.Unknown, + input, + head, + tail, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + + return true; + } + + private static (string Head, string Tail) SplitHeadAndArgs(string input) => CommandLineHelper.SplitHeadAndArgs(input); + + // Finds the best existing path prefix in an *unquoted* input by scanning + // whitespace boundaries. Prefers files to directories; for same kind, + // prefers the longer path. + // Returns (head, tail) or (null, null) if nothing found. + private static (string? Head, string? Tail) ExpandToBestExistingPath(string head, string tail, bool containsPlaceholders, IPlaceholderParser placeholderParser) + { + try + { + // This goes greedy from the longest head down to shortest; exactly opposite of what + // CreateProcess rules are for the first token. But here we operate with a slightly different goal. + var (greedyHead, greedyTail) = GreedyFind(head, containsPlaceholders, placeholderParser); + + // put tails back together: + return (Head: greedyHead, string.Join(" ", greedyTail, tail).Trim()); + } + catch (Exception ex) + { + Logger.LogError("Failed to find best path", ex); + throw; + } + } + + private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser) + { + // Be greedy: try to find the longest existing path prefix + for (var i = input.Length; i >= 0; i--) + { + if (i < input.Length && !char.IsWhiteSpace(input[i])) + { + continue; + } + + var candidate = input.AsSpan(0, i).TrimEnd().ToString(); + if (candidate.Length == 0) + { + continue; + } + + // If we have placeholders, check if this candidate would contain a non-path placeholder + if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser)) + { + continue; // Skip this candidate, try a shorter one + } + + try + { + if (CommandLineHelper.ExpandPathToPhysicalFile(candidate, true, out var full)) + { + var tail = i < input.Length ? input[i..].TrimStart() : string.Empty; + return (full, tail); + } + } + catch + { + // Ignore malformed paths; keep scanning + } + } + + return (null, null); + } + + // Attempts to guess if any placeholders in the candidate string are likely not part of a filesystem path. + private static bool ContainsNonPathPlaceholder(string candidate, IPlaceholderParser placeholderParser) + { + placeholderParser.ParsePlaceholders(candidate, out _, out var placeholders); + foreach (var match in placeholders) + { + var placeholderContext = GuessPlaceholderContextInFileSystemPath(candidate, match.Index); + + // If placeholder appears after what looks like a command-line flag/option + if (placeholderContext.IsAfterFlag) + { + return true; + } + + // If placeholder doesn't look like a typical path component + if (!placeholderContext.LooksLikePathComponent) + { + return true; + } + } + + return false; + } + + // Heuristically determines the context of a placeholder inside a filesystem-like input string. + // Sets: + // - IsAfterFlag: true if immediately preceded by a token that looks like a command-line flag prefix (" -", " /", " --"). + // - LooksLikePathComponent: true if (a) not after a flag or (b) nearby text shows path separators. + private static PlaceholderContext GuessPlaceholderContextInFileSystemPath(string input, int placeholderIndex) + { + var beforePlaceholder = input[..placeholderIndex].TrimEnd(); + + var isAfterFlag = beforePlaceholder.EndsWith(" -", StringComparison.OrdinalIgnoreCase) || + beforePlaceholder.EndsWith(" /", StringComparison.OrdinalIgnoreCase) || + beforePlaceholder.EndsWith(" --", StringComparison.OrdinalIgnoreCase); + + var looksLikePathComponent = !isAfterFlag; + + var nearbyText = input.Substring(Math.Max(0, placeholderIndex - 20), Math.Min(40, input.Length - Math.Max(0, placeholderIndex - 20))); + var hasPathSeparators = nearbyText.Contains('\\') || nearbyText.Contains('/'); + + if (!hasPathSeparators && isAfterFlag) + { + looksLikePathComponent = false; + } + + return new PlaceholderContext(isAfterFlag, looksLikePathComponent); + } + + private static bool TryGetAumid(string input, out string aumid) + { + // App ids are a lot of fun, since they can look like anything. + // And yes, they can contain spaces too, like Zoom: + // shell:AppsFolder\zoom.us.Zoom Video Meetings + // so unless that thing is quoted, we can't just assume the first token is the AUMID. + const string appsFolder = "shell:AppsFolder\\"; + + // Guard against null or empty input + if (string.IsNullOrEmpty(input)) + { + aumid = string.Empty; + return false; + } + + // Already a fully qualified AUMID path + if (input.StartsWith(appsFolder, StringComparison.OrdinalIgnoreCase)) + { + aumid = input; + return true; + } + + aumid = string.Empty; + return false; + } + + private static bool LooksPathy(string input) + { + // Basic: drive:\, UNC, relative with . or .., or has dir separator + if (input.Contains('\\') || input.Contains('/')) + { + return true; + } + + if (input is [_, ':', ..]) + { + return true; + } + + if (input.StartsWith(@"\\", StringComparison.InvariantCulture) || input.StartsWith("./", StringComparison.InvariantCulture) || input.StartsWith(".\\", StringComparison.InvariantCulture) || input.StartsWith("..\\", StringComparison.InvariantCulture)) + { + return true; + } + + return false; + } + + private static bool HasDir(string path) => !string.IsNullOrEmpty(Path.GetDirectoryName(path)); + + private static bool TryResolveViaPath(string head, out string resolvedFile) + { + resolvedFile = string.Empty; + + if (string.IsNullOrWhiteSpace(head)) + { + return false; + } + + if (Path.HasExtension(head) && ShellHelpers.FileExistInPath(head, out resolvedFile)) + { + return true; + } + + // If head has dir, treat as path probe + if (HasDir(head)) + { + if (Path.HasExtension(head)) + { + var p = TryProbe(Environment.CurrentDirectory, head); + if (p is not null) + { + resolvedFile = p; + return true; + } + + return false; + } + + foreach (var ext in ShellHelpers.ExecutableExtensions) + { + var p = TryProbe(null, head + ext); + if (p is not null) + { + resolvedFile = p; + return true; + } + } + + return false; + } + + return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile); + } + + private static string? TryProbe(string? dir, string name) + { + try + { + var path = dir is null ? name : Path.Combine(dir, name); + if (File.Exists(path)) + { + return Path.GetFullPath(path); + } + } + catch + { + /* ignore */ + } + + return null; + } + + private record PlaceholderContext(bool IsAfterFlag, bool LooksLikePathComponent); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs new file mode 100644 index 0000000000..541ecdf19d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs @@ -0,0 +1,157 @@ +// 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.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public sealed partial class FaviconLoader : IFaviconLoader, IDisposable +{ + private readonly HttpClient _http = CreateClient(); + private bool _disposed; + + private static HttpClient CreateClient() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + }; + + var client = new HttpClient(handler, disposeHandler: true); + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WindowsCommandPalette/1.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("image/*"); + + return client; + } + + public async Task TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default) + { + if (siteUri.Scheme != Uri.UriSchemeHttp && siteUri.Scheme != Uri.UriSchemeHttps) + { + return null; + } + + // 1) First attempt: favicon on the original authority (preserves port). + var first = BuildFaviconUri(siteUri); + + // Try download; if this fails (non-image or path lost), retry on final host. + var stream = await TryDownloadImageAsync(first, ct).ConfigureAwait(false); + if (stream is not null) + { + return stream; + } + + // 2) If the server redirected and "lost" the path, try /favicon.ico on the *final* host. + // We discover the final host by doing a HEAD/GET to the original URL and inspecting the final RequestUri. + var finalAuthority = await ResolveFinalAuthorityAsync(first, ct).ConfigureAwait(false); + if (finalAuthority is null || UriEqualsAuthority(first, finalAuthority)) + { + return null; + } + + var second = BuildFaviconUri(finalAuthority); + if (second == first) + { + return null; // nothing new to try + } + + return await TryDownloadImageAsync(second, ct).ConfigureAwait(false); + } + + private static Uri BuildFaviconUri(Uri anyUriOnSite) + { + var b = new UriBuilder(anyUriOnSite.Scheme, anyUriOnSite.Host) + { + Port = anyUriOnSite.IsDefaultPort ? -1 : anyUriOnSite.Port, + Path = "/favicon.ico", + }; + return b.Uri; + } + + private async Task ResolveFinalAuthorityAsync(Uri url, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + + // We only need headers to learn the final RequestUri after redirects + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + var final = resp.RequestMessage?.RequestUri; + return final is null ? null : new UriBuilder(final.Scheme, final.Host) + { + Port = final.IsDefaultPort ? -1 : final.Port, + Path = "/", + }.Uri; + } + + private async Task TryDownloadImageAsync(Uri url, CancellationToken ct) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (!resp.IsSuccessStatusCode) + { + return null; + } + + // If the redirect chain dumped us on an HTML page (common for root), bail. + var mediaType = resp.Content.Headers.ContentType?.MediaType; + if (mediaType is not null && + !mediaType.StartsWith("image", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var bytes = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + var stream = new InMemoryRandomAccessStream(); + + using (var output = stream.GetOutputStreamAt(0)) + using (var writer = new DataWriter(output)) + { + writer.WriteBytes(bytes); + await writer.StoreAsync().AsTask(ct); + await writer.FlushAsync().AsTask(ct); + } + + stream.Seek(0); + return stream; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + + private static bool UriEqualsAuthority(Uri a, Uri b) + => a.Scheme.Equals(b.Scheme, StringComparison.OrdinalIgnoreCase) + && a.Host.Equals(b.Host, StringComparison.OrdinalIgnoreCase) + && (a.IsDefaultPort ? -1 : a.Port) == (b.IsDefaultPort ? -1 : b.Port); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _http.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs new file mode 100644 index 0000000000..5ed8133277 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs @@ -0,0 +1,15 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public interface IBookmarkIconLocator +{ + Task GetIconForPath(Classification classification, CancellationToken cancellationToken = default); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs new file mode 100644 index 0000000000..225c99d5a8 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs @@ -0,0 +1,16 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal interface IBookmarkResolver +{ + Task<(bool Success, Classification Result)> TryClassifyAsync(string input, CancellationToken cancellationToken = default); + + Classification ClassifyOrUnknown(string input); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs new file mode 100644 index 0000000000..cd9c3007de --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs @@ -0,0 +1,17 @@ +// 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.Threading; +using System.Threading.Tasks; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +/// +/// Service to load favicons for websites. +/// +public interface IFaviconLoader +{ + Task TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs new file mode 100644 index 0000000000..c357c7235b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public interface IPlaceholderParser +{ + bool ParsePlaceholders(string input, out string head, out List placeholders); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs new file mode 100644 index 0000000000..0a855f5886 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs @@ -0,0 +1,258 @@ +// 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.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal class IconLocator : IBookmarkIconLocator +{ + private readonly IFaviconLoader _faviconLoader; + + public IconLocator() + : this(new FaviconLoader()) + { + } + + private IconLocator(IFaviconLoader faviconLoader) + { + ArgumentNullException.ThrowIfNull(faviconLoader); + _faviconLoader = faviconLoader; + } + + public async Task GetIconForPath( + Classification classification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(classification); + + var icon = classification.Kind switch + { + CommandKind.WebUrl => await TryGetWebIcon(classification.Target), + CommandKind.Protocol => await TryGetProtocolIcon(classification.Target), + CommandKind.FileExecutable => await TryGetExecutableIcon(classification.Target), + CommandKind.Unknown => FallbackIcon(classification), + _ => await MaybeGetIconForPath(classification.Target), + }; + + return icon ?? FallbackIcon(classification); + } + + private async Task TryGetWebIcon(string target) + { + // Get the base url up to the first placeholder + var placeholderIndex = target.IndexOf('{'); + var baseString = placeholderIndex > 0 ? target[..placeholderIndex] : target; + try + { + var uri = new Uri(baseString); + var iconStream = await _faviconLoader.TryGetFaviconAsync(uri, CancellationToken.None); + if (iconStream != null) + { + return IconInfo.FromStream(iconStream); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get web bookmark favicon for " + baseString, ex); + } + + return null; + } + + private static async Task TryGetExecutableIcon(string target) + { + IIconInfo? icon = null; + 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 is not null) + { + return icon; + } + } + + return icon; + } + + private static async Task TryGetProtocolIcon(string target) + { + // Special case for steam: protocol - use game icon + // Steam protocol have only a file name (steam.exe) associated with it, but is not + // in PATH or AppPaths. So we can't resolve it to an executable. But at the same time, + // this is a very common protocol, so we special-case it here. + if (target.StartsWith("steam:", StringComparison.OrdinalIgnoreCase)) + { + return Icons.BookmarkTypes.Game; + } + + // extract protocol from classification.Target (until the first ':'): + IconInfo? icon = null; + var colonIndex = target.IndexOf(':'); + string protocol; + if (colonIndex > 0) + { + protocol = target[..colonIndex]; + } + else + { + return icon; + } + + icon = await ThumbnailHelper.GetProtocolIconStream(protocol, true) is { } stream + ? IconInfo.FromStream(stream) + : null; + + if (icon is null) + { + var protocolIconPath = ProtocolIconResolver.GetIconString(protocol); + if (protocolIconPath is not null) + { + icon = new IconInfo(protocolIconPath); + } + } + + return icon; + } + + private static IconInfo FallbackIcon(Classification classification) + { + return classification.Kind switch + { + CommandKind.FileExecutable => Icons.BookmarkTypes.Application, + CommandKind.FileDocument => Icons.BookmarkTypes.FilePath, + CommandKind.Directory => Icons.BookmarkTypes.FolderPath, + CommandKind.PathCommand => Icons.BookmarkTypes.Command, + CommandKind.Aumid => Icons.BookmarkTypes.Application, + CommandKind.Shortcut => Icons.BookmarkTypes.Application, + CommandKind.InternetShortcut => Icons.BookmarkTypes.WebUrl, + CommandKind.WebUrl => Icons.BookmarkTypes.WebUrl, + CommandKind.Protocol => Icons.BookmarkTypes.Application, + _ => Icons.BookmarkTypes.Unknown, + }; + } + + private static async Task MaybeGetIconForPath(string target) + { + try + { + var stream = await ThumbnailHelper.GetThumbnail(target); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } + + if (ShellNames.TryGetFileSystemPath(target, out var fileSystemPath)) + { + stream = await ThumbnailHelper.GetThumbnail(fileSystemPath); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } + } + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to load icon for {target}\n" + ex); + } + + return null; + } + + internal static class ProtocolIconResolver + { + /// + /// Gets the icon resource string for a given URI protocol (e.g. "steam" or "mailto"). + /// Returns something like "C:\Path\app.exe,0" or null if not found. + /// + public static string? GetIconString(string protocol) + { + try + { + if (string.IsNullOrWhiteSpace(protocol)) + { + return null; + } + + protocol = protocol.TrimEnd(':').ToLowerInvariant(); + + // Try HKCR\\DefaultIcon + using (var di = Registry.ClassesRoot.OpenSubKey(protocol + "\\DefaultIcon")) + { + var value = di?.GetValue(null) as string; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + // Fallback: HKCR\\shell\open\command + using (var cmd = Registry.ClassesRoot.OpenSubKey(protocol + "\\shell\\open\\command")) + { + var command = cmd?.GetValue(null) as string; + if (!string.IsNullOrWhiteSpace(command)) + { + var exe = ExtractExecutable(command); + if (!string.IsNullOrWhiteSpace(exe)) + { + return exe; // default index 0 implied + } + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get protocol information from registry; will return nothing instead", ex); + } + + return null; + } + + private static string ExtractExecutable(string command) + { + command = command.Trim(); + + if (command.StartsWith('\"')) + { + var end = command.IndexOf('"', 1); + if (end > 1) + { + return command[1..end]; + } + } + + var space = command.IndexOf(' '); + return space > 0 ? command[..space] : command; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs new file mode 100644 index 0000000000..1a8254a33a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public sealed class PlaceholderInfo +{ + public string Name { get; } + + public int Index { get; } + + public PlaceholderInfo(string name, int index) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentOutOfRangeException.ThrowIfLessThan(index, 0); + + Name = name; + Index = index; + } + + private bool Equals(PlaceholderInfo other) => Name == other.Name && Index == other.Index; + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((PlaceholderInfo)obj); + } + + public override int GetHashCode() => HashCode.Combine(Name, Index); + + public static bool operator ==(PlaceholderInfo? left, PlaceholderInfo? right) + { + return Equals(left, right); + } + + public static bool operator !=(PlaceholderInfo? left, PlaceholderInfo? right) + { + return !Equals(left, right); + } + + public override string ToString() => Name; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs new file mode 100644 index 0000000000..7841e91c47 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs @@ -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. + +using System; +using System.Collections.Generic; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public class PlaceholderInfoNameEqualityComparer : IEqualityComparer +{ + public static PlaceholderInfoNameEqualityComparer Instance { get; } = new(); + + public bool Equals(PlaceholderInfo? x, PlaceholderInfo? y) + { + if (x is null && y is null) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(PlaceholderInfo obj) + { + ArgumentNullException.ThrowIfNull(obj); + return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs new file mode 100644 index 0000000000..17c88a1ddf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public class PlaceholderParser : IPlaceholderParser +{ + public bool ParsePlaceholders(string input, out string head, out List placeholders) + { + ArgumentNullException.ThrowIfNull(input); + + head = string.Empty; + placeholders = []; + + if (string.IsNullOrEmpty(input)) + { + head = string.Empty; + return false; + } + + var foundPlaceholders = new List(); + var searchStart = 0; + var firstPlaceholderStart = -1; + var hasValidPlaceholder = false; + + while (searchStart < input.Length) + { + var openBrace = input.IndexOf('{', searchStart); + if (openBrace == -1) + { + break; + } + + var closeBrace = input.IndexOf('}', openBrace + 1); + if (closeBrace == -1) + { + break; + } + + // Extract potential placeholder name + var placeholderContent = input.Substring(openBrace + 1, closeBrace - openBrace - 1); + + // Check if it's a valid placeholder + if (!string.IsNullOrEmpty(placeholderContent) && + !IsGuidFormat(placeholderContent) && + IsValidPlaceholderName(placeholderContent)) + { + // Valid placeholder found + foundPlaceholders.Add(new PlaceholderInfo(placeholderContent, openBrace)); + hasValidPlaceholder = true; + + // Remember the first valid placeholder position + if (firstPlaceholderStart == -1) + { + firstPlaceholderStart = openBrace; + } + } + + // Continue searching after this brace pair + searchStart = closeBrace + 1; + } + + // Convert to Placeholder objects + placeholders = foundPlaceholders; + + if (hasValidPlaceholder) + { + head = input[..firstPlaceholderStart]; + return true; + } + else + { + head = input; + return false; + } + } + + private static bool IsValidPlaceholderName(string name) + { + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-')) + { + return false; + } + } + + return true; + } + + private static bool IsGuidFormat(string content) => Guid.TryParse(content, out _); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs deleted file mode 100644 index db60a31940..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.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 -{ - private readonly Lazy _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) - { - } - - public UrlCommand(string name, string url) - { - Name = Properties.Resources.bookmarks_command_name_open; - - Url = url; - - _icon = new Lazy(() => - { - ShellHelpers.ParseExecutableAndArgs(Url, out var exe, out var args); - var t = GetIconForPath(exe); - t.Wait(); - return t.Result; - }); - } - - public override CommandResult Invoke() - { - 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 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 is not null) - { - _ = Launcher.LaunchUriAsync(uri); - } - else - { - Logger.LogError("The provided URL is not valid."); - } - - return true; - } - - return false; - } - - internal static Uri? GetUri(string url) - { - Uri? uri; - if (!Uri.TryCreate(url, UriKind.Absolute, out uri)) - { - if (!Uri.TryCreate("https://" + url, UriKind.Absolute, out uri)) - { - return null; - } - } - - return uri; - } - - public static async Task GetIconForPath(string target) - { - IconInfo? icon = null; - - // First, try to get the icon from the thumbnail helper - // This works for local files and folders - icon = await MaybeGetIconForPath(target); - if (icon is not 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 is not 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 is not 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 MaybeGetIconForPath(string target) - { - try - { - var stream = await ThumbnailHelper.GetThumbnail(target); - if (stream is not null) - { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - return new IconInfo(data, data); - } - } - catch - { - } - - return null; - } -} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs index b59560cc7c..ff655387e0 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs @@ -13,6 +13,7 @@ public partial class OpenInConsoleCommand : InvokableCommand internal static IconInfo OpenInConsoleIcon { get; } = new("\uE756"); // "CommandPrompt" private readonly string _path; + private bool _isDirectory; public OpenInConsoleCommand(string fullPath) { @@ -21,11 +22,15 @@ public partial class OpenInConsoleCommand : InvokableCommand this.Icon = OpenInConsoleIcon; } + public static OpenInConsoleCommand FromDirectory(string directory) => new(directory) { _isDirectory = true }; + + public static OpenInConsoleCommand FromFile(string file) => new(file); + public override CommandResult Invoke() { using (var process = new Process()) { - process.StartInfo.WorkingDirectory = Path.GetDirectoryName(_path); + process.StartInfo.WorkingDirectory = _isDirectory ? _path : Path.GetDirectoryName(_path); process.StartInfo.FileName = "cmd.exe"; try diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs index 99db456619..ccbe6ed885 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs @@ -6,11 +6,23 @@ using System.Runtime.InteropServices; namespace Microsoft.CommandPalette.Extensions.Toolkit; -internal sealed class NativeMethods +internal static partial class NativeMethods { [DllImport("shell32.dll", CharSet = CharSet.Unicode)] internal static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags); + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + internal static extern IntPtr SHGetFileInfo(IntPtr pidl, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags); + + [DllImport("shell32.dll")] + internal static extern int SHParseDisplayName([MarshalAs(UnmanagedType.LPWStr)] string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut); + + [DllImport("ole32.dll")] + internal static extern void CoTaskMemFree(IntPtr pv); + + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] + internal static extern int SHLoadIndirectString(string pszSource, System.Text.StringBuilder pszOutBuf, int cchOutBuf, IntPtr ppvReserved); + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct SHFILEINFO { @@ -33,4 +45,58 @@ internal sealed class NativeMethods [DllImport("comctl32.dll", SetLastError = true)] internal static extern int ImageList_GetIcon(IntPtr himl, int i, int flags); + + [LibraryImport("shlwapi.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static unsafe partial int AssocQueryStringW( + AssocF flags, + AssocStr str, + string pszAssoc, + string? pszExtra, + char* pszOut, + ref uint pcchOut); + + // SHDefExtractIconW lets us ask for specific sizes (incl. 256) + // nIconSize: HIWORD = large size, LOWORD = small size + [LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static partial int SHDefExtractIconW( + string pszIconFile, + int iIndex, + uint uFlags, + out nint phiconLarge, + out nint phiconSmall, + int nIconSize); + + [Flags] + public enum AssocF : uint + { + None = 0, + IsProtocol = 0x00001000, + } + + public enum AssocStr + { + Command = 1, + Executable, + FriendlyDocName, + FriendlyAppName, + NoOpen, + ShellNewValue, + DDECommand, + DDEIfExec, + DDEApplication, + DDETopic, + InfoTip, + QuickTip, + TileInfo, + ContentType, + DefaultIcon, + ShellExtension, + DropTarget, + DelegateExecute, + SupportedUriProtocols, + ProgId, + AppId, + AppPublisher, + AppIconReference, // sometimes present, but DefaultIcon is most common + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs index 6c761edcf2..3ddbcc5ad6 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs @@ -4,11 +4,68 @@ using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Win32; namespace Microsoft.CommandPalette.Extensions.Toolkit; public static class ShellHelpers { + /// + /// These are the executable file extensions that Windows Shell recognizes. Unlike CMD/PowerShell, + /// Shell does not use PATHEXT, but has a magic fixed list. + /// + public static string[] ExecutableExtensions { get; } = [".PIF", ".COM", ".EXE", ".BAT", ".CMD"]; + + /// + /// Determines whether the specified file name represents an executable file + /// by examining its extension against the known list of Windows Shell + /// executable extensions (a fixed list that does not honor PATHEXT). + /// + /// The file name (with or without path) whose extension will be evaluated. + /// + /// True if the file name has an extension that matches one of the recognized executable + /// extensions; otherwise, false. Returns false for null, empty, or whitespace input. + /// + public static bool IsExecutableFile(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + var fileExtension = Path.GetExtension(fileName); + return IsExecutableExtension(fileExtension); + } + + /// + /// Determines whether the provided file extension (including the leading dot) + /// is one of the Windows Shell recognized executable extensions. + /// + /// The file extension to test. Should include the leading dot (e.g. ".exe"). + /// + /// True if the extension matches (case-insensitive) one of the known executable + /// extensions; false if it does not match or if the input is null/whitespace. + /// + public static bool IsExecutableExtension(string fileExtension) + { + if (string.IsNullOrWhiteSpace(fileExtension)) + { + // Shell won't execute app with a filename without an extension + return false; + } + + foreach (var extension in ExecutableExtensions) + { + if (string.Equals(fileExtension, extension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + public static bool OpenCommandInShell(string? path, string? pattern, string? arguments, string? workingDir = null, ShellRunAsType runAs = ShellRunAsType.None, bool runWithHiddenWindow = false) { if (string.IsNullOrEmpty(pattern)) @@ -127,7 +184,7 @@ public static class ShellHelpers var values = Environment.GetEnvironmentVariable("PATH"); if (values is not null) { - foreach (var path in values.Split(';')) + foreach (var path in values.Split(Path.PathSeparator)) { var path1 = Path.Combine(path, filename); if (File.Exists(path1)) @@ -147,13 +204,78 @@ public static class ShellHelpers token?.ThrowIfCancellationRequested(); } + } - return false; - } - else - { - return false; - } + return false; } } + + private static bool TryResolveFromAppPaths(string name, [NotNullWhen(true)] out string? fullPath) + { + try + { + fullPath = TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry64) ?? + TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry32) ?? + TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry64) ?? + TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry32) ?? string.Empty; + + return !string.IsNullOrEmpty(fullPath); + + string? TryHiveView(RegistryHive hive, RegistryView view) + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var k1 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}.exe"); + var val = (k1?.GetValue(null) as string)?.Trim('"'); + if (!string.IsNullOrEmpty(val)) + { + return val; + } + + // Some vendors create keys without .exe in the subkey name; check that too. + using var k2 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}"); + return (k2?.GetValue(null) as string)?.Trim('"'); + } + } + catch (Exception) + { + fullPath = null; + return false; + } + } + + /// + /// Mimics Windows Shell behavior to resolve an executable name to a full path. + /// + /// + /// + /// + public static bool TryResolveExecutableAsShell(string name, out string fullPath) + { + // First check if we can find the file in the registry + if (TryResolveFromAppPaths(name, out var path)) + { + fullPath = path; + return true; + } + + // If the name does not have an extension, try adding common executable extensions + // this order mimics Windows Shell behavior + // Note: HasExtension check follows Shell behavior, but differs from the + // Start Menu search results, which will offer file name with extensions + ".exe" + var nameHasExtension = Path.HasExtension(name); + if (!nameHasExtension) + { + foreach (var ext in ExecutableExtensions) + { + var nameWithExt = name + ext; + if (FileExistInPath(nameWithExt, out fullPath)) + { + return true; + } + } + } + + fullPath = string.Empty; + return false; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs index e06dbfcb22..06fa1a2c28 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs @@ -2,16 +2,18 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Drawing; using System.Globalization; using System.Runtime.InteropServices; +using System.Text; using Windows.Storage; using Windows.Storage.FileProperties; using Windows.Storage.Streams; namespace Microsoft.CommandPalette.Extensions.Toolkit; -public class ThumbnailHelper +public static class ThumbnailHelper { private static readonly string[] ImageExtensions = [ @@ -24,26 +26,46 @@ public class ThumbnailHelper ".ico", ]; - public static Task GetThumbnail(string path, bool jumbo = false) + public static async Task GetThumbnail(string path, bool jumbo = false) { var extension = Path.GetExtension(path).ToLower(CultureInfo.InvariantCulture); + var isImage = ImageExtensions.Contains(extension); + if (isImage) + { + try + { + var result = await GetImageThumbnailAsync(path, jumbo); + if (result is not null) + { + return result; + } + } + catch (Exception) + { + // ignore and fall back to icon + } + } + try { - return ImageExtensions.Contains(extension) ? GetImageThumbnailAsync(path) : GetFileIconStream(path, jumbo); + return await GetFileIconStream(path, jumbo); } catch (Exception) { + // ignore and return null } - return Task.FromResult(null); + return null; } // these are windows constants and mangling them is goofy #pragma warning disable SA1310 // Field names should not contain underscore #pragma warning disable SA1306 // Field names should begin with lower-case letter private const uint SHGFI_ICON = 0x000000100; + private const uint SHGFI_LARGEICON = 0x000000000; private const uint SHGFI_SHELLICONSIZE = 0x000000004; - private const int SHGFI_SYSICONINDEX = 0x000004000; + private const uint SHGFI_SYSICONINDEX = 0x000004000; + private const uint SHGFI_PIDL = 0x000000008; private const int SHIL_JUMBO = 4; private const int ILD_TRANSPARENT = 1; #pragma warning restore SA1306 // Field names should begin with lower-case letter @@ -69,6 +91,59 @@ public class ThumbnailHelper } private static async Task GetFileIconStream(string filePath, bool jumbo) + { + return await TryExtractUsingPIDL(filePath, jumbo) + ?? await GetFileIconStreamUsingFilePath(filePath, jumbo); + } + + private static async Task TryExtractUsingPIDL(string shellPath, bool jumbo) + { + IntPtr pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, IntPtr.Zero, out pidl, 0, out _); + if (hr != 0 || pidl == IntPtr.Zero) + { + return null; + } + + nint hIcon = 0; + if (jumbo) + { + hIcon = GetLargestIcon(pidl); + } + + if (hIcon == 0) + { + var shinfo = default(NativeMethods.SHFILEINFO); + var fileInfoResult = NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_ICON | SHGFI_SHELLICONSIZE | SHGFI_LARGEICON | SHGFI_PIDL); + if (fileInfoResult != IntPtr.Zero && shinfo.hIcon != IntPtr.Zero) + { + hIcon = shinfo.hIcon; + } + } + + if (hIcon == 0) + { + return null; + } + + return await FromHIconToStream(hIcon); + } + catch (Exception) + { + return null; + } + finally + { + if (pidl != IntPtr.Zero) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } + + private static async Task GetFileIconStreamUsingFilePath(string filePath, bool jumbo) { nint hIcon = 0; @@ -99,41 +174,262 @@ public class ThumbnailHelper return null; } - var stream = new InMemoryRandomAccessStream(); - - using var memoryStream = GetMemoryStreamFromIcon(hIcon); // this will DestroyIcon hIcon - using var outputStream = stream.GetOutputStreamAt(0); - using (var dataWriter = new DataWriter(outputStream)) - { - dataWriter.WriteBytes(memoryStream.ToArray()); - await dataWriter.StoreAsync(); - await dataWriter.FlushAsync(); - } - - return stream; + return await FromHIconToStream(hIcon); } - private static async Task GetImageThumbnailAsync(string filePath) + private static async Task GetImageThumbnailAsync(string filePath, bool jumbo) { var file = await StorageFile.GetFileFromPathAsync(filePath); - var thumbnail = await file.GetThumbnailAsync(ThumbnailMode.PicturesView); + var thumbnail = await file.GetThumbnailAsync( + jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView, + jumbo ? 64u : 20u); return thumbnail; } + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 Naming/Private")] + private static readonly Guid IID_IImageList = new Guid("46EB5926-582E-4017-9FDF-E8998DAA0950"); + private static nint GetLargestIcon(string path) { var shinfo = default(NativeMethods.SHFILEINFO); NativeMethods.SHGetFileInfo(path, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX); var hIcon = IntPtr.Zero; - var iID_IImageList = new Guid("46EB5926-582E-4017-9FDF-E8998DAA0950"); - IntPtr imageListPtr; + var iID_IImageList = IID_IImageList; - if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out imageListPtr) == 0 && imageListPtr != IntPtr.Zero) + if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero) { hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT); } return hIcon; } + + private static nint GetLargestIcon(IntPtr pidl) + { + var shinfo = default(NativeMethods.SHFILEINFO); + NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX | SHGFI_PIDL); + + var hIcon = IntPtr.Zero; + var iID_IImageList = IID_IImageList; + + if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero) + { + hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT); + } + + return hIcon; + } + + /// + /// Get an icon stream for a registered URI protocol (e.g. "mailto:", "http:", "steam:"). + /// + public static async Task GetProtocolIconStream(string protocol, bool jumbo) + { + // 1) Ask the shell for the protocol's default icon "path,index" + var iconRef = QueryProtocolIconReference(protocol); + if (string.IsNullOrWhiteSpace(iconRef)) + { + return null; + } + + // Indirect reference: + if (iconRef.StartsWith('@')) + { + if (TryLoadIndirectString(iconRef, out var expanded) && !string.IsNullOrWhiteSpace(expanded)) + { + iconRef = expanded; + } + } + + // 2) Handle .png files returned by store apps + if (iconRef.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + { + try + { + var file = await StorageFile.GetFileFromPathAsync(iconRef); + var thumbnail = await file.GetThumbnailAsync( + jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView, + jumbo ? 64u : 20u); + return thumbnail; + } + catch (Exception) + { + return null; + } + } + + // 3) Parse "path,index" (index can be negative) + if (!TryParseIconReference(iconRef, out var path, out var index)) + { + return null; + } + + // if it's and .exe and without a path, let's find on path: + if (Path.GetExtension(path).Equals(".exe", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(path)) + { + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(';') ?? []; + foreach (var p in paths) + { + var candidate = Path.Combine(p, path); + if (File.Exists(candidate)) + { + path = candidate; + break; + } + } + } + + // 3) Extract an HICON, preferably ~256px when jumbo==true + var hIcon = ExtractIconHandle(path, index, jumbo); + if (hIcon == 0) + { + return null; + } + + return await FromHIconToStream(hIcon); + } + + private static bool TryLoadIndirectString(string input, out string? output) + { + var outBuffer = new StringBuilder(1024); + var hr = NativeMethods.SHLoadIndirectString(input, outBuffer, outBuffer.Capacity, IntPtr.Zero); + if (hr == 0) + { + output = outBuffer.ToString(); + return !string.IsNullOrWhiteSpace(output); + } + + output = null; + return false; + } + + private static async Task FromHIconToStream(IntPtr hIcon) + { + var stream = new InMemoryRandomAccessStream(); + + using var memoryStream = GetMemoryStreamFromIcon(hIcon); // this will DestroyIcon hIcon + using var outputStream = stream.GetOutputStreamAt(0); + using var dataWriter = new DataWriter(outputStream); + + dataWriter.WriteBytes(memoryStream.ToArray()); + await dataWriter.StoreAsync(); + await dataWriter.FlushAsync(); + + return stream; + } + + private static string? QueryProtocolIconReference(string protocol) + { + // First try DefaultIcon (most widely populated for protocols) + // If you want to try AppIconReference as a fallback, you can repeat with AssocStr.AppIconReference. + var iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.DefaultIcon, protocol); + if (!string.IsNullOrWhiteSpace(iconReference)) + { + return iconReference; + } + + // Optional fallback – some registrations use AppIconReference: + iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.AppIconReference, protocol); + return iconReference; + + static unsafe string? AssocQueryStringSafe(NativeMethods.AssocStr what, string protocol) + { + uint cch = 0; + + // First call: get required length (incl. null) + _ = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, null, ref cch); + if (cch == 0) + { + return null; + } + + // Small buffers on stack; large on heap + var span = cch <= 512 ? stackalloc char[(int)cch] : new char[(int)cch]; + + fixed (char* p = span) + { + var hr = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, p, ref cch); + if (hr != 0 || cch == 0) + { + return null; + } + + // cch includes the null terminator; slice it off + var len = (int)cch - 1; + if (len < 0) + { + len = 0; + } + + return new string(span.Slice(0, len)); + } + } + } + + private static bool TryParseIconReference(string iconRef, out string path, out int index) + { + // Typical shapes: + // "C:\Program Files\Outlook\OUTLOOK.EXE,-1" + // "shell32.dll,21" + // "\"C:\Some Path\app.dll\",-325" + + // If there's no comma, assume ",0" + index = 0; + path = iconRef.Trim(); + + // Split only on the last comma so paths with commas still work + var lastComma = path.LastIndexOf(','); + if (lastComma >= 0) + { + var idxPart = path[(lastComma + 1)..].Trim(); + path = path[..lastComma].Trim(); + _ = int.TryParse(idxPart, out index); + } + + // Trim quotes around path + path = path.Trim('"'); + if (path.Length > 1 && path[0] == '"' && path[^1] == '"') + { + path = path.Substring(1, path.Length - 2); + } + + // Basic sanity + return !string.IsNullOrWhiteSpace(path); + } + + private static nint ExtractIconHandle(string path, int index, bool jumbo) + { + // Request sizes: LOWORD=small, HIWORD=large. + // Ask for 256 when jumbo, else fall back to 32/16. + var small = jumbo ? 256 : 16; + var large = jumbo ? 256 : 32; + var sizeParam = (large << 16) | (small & 0xFFFF); + + var hr = NativeMethods.SHDefExtractIconW(path, index, 0, out var hLarge, out var hSmall, sizeParam); + if (hr == 0 && hLarge != 0) + { + return hLarge; + } + + if (hr == 0 && hSmall != 0) + { + return hSmall; + } + + // Final fallback: try 32/16 explicitly in case the resource can’t upscale + sizeParam = (32 << 16) | 16; + hr = NativeMethods.SHDefExtractIconW(path, index, 0, out hLarge, out hSmall, sizeParam); + if (hr == 0 && hLarge != 0) + { + return hLarge; + } + + if (hr == 0 && hSmall != 0) + { + return hSmall; + } + + return 0; + } }