CmdPal: Make Bookmarks Great and Fast Again (#41961)

## Summary of the Pull Request


This PR improves recognition and classification of bookmarks, allowing
CmdPal to recognize almost anything sensible a user can throw at
it—while being forgiving of common input issues (such as unquoted spaces
in paths, etc.).

Extended classification and exploration of edge cases also revealed
limitations in the current implementation, which reloaded all bookmarks
on every change. This caused visible UI lag and could lead to issues
like unintentionally adding the same bookmark multiple times.

### tl;dr

More details below

- Introduces `BookmarkManager` (async saves, thread-safe, immutable,
unique IDs, separate persistence).
- Adds `BookmarkResolver` (classification, Shell-like path/exe
resolution, better icons).
- `BookmarkListItem` now refreshes independently; Name is optional
(Shell fallback).
- Uses Shell API for user-friendly names and paths.  
- Adds `IIconLocator`, protocol icon support, Steam custom icon,
fallback icons and improved `FaviconLoader` (handles redirects). Every
bookmark should now have icon, so we have consistent UI without gaps.
- Refactors placeholders (`IPlaceholderParser`), adds tests, restricts
names to `[a-zA-Z0-9_-]`, excludes GUIDs.
- Reorganizes structure, syncs icons/key chords with AllApps/Indexer.  
- For web and protocol bookmarks URL-encodes placeholder values
- **Performance:** avoids full reloads, improves scalability, reduces UI
lag.
- **Breaking change:** stricter placeholder rules, bookmark command ids.


<img width="786" height="1392" alt="image"
src="https://github.com/user-attachments/assets/88d6617a-9f7c-47d1-bd60-80593fe414d3"
/>

<img width="786" height="1389" alt="image"
src="https://github.com/user-attachments/assets/8cdd3a09-73ae-439a-94ef-4e14d14c1ef3"
/>

<img width="896" height="461" alt="image"
src="https://github.com/user-attachments/assets/1f32e230-7d32-4710-b4c5-28e202c0e37b"
/>

<img width="862" height="391" alt="image"
src="https://github.com/user-attachments/assets/7649ce6a-3471-46f2-adc4-fb21bd4ecfed"
/>

<img width="844" height="356" alt="image"
src="https://github.com/user-attachments/assets/0c0b1941-fe5c-474e-94e9-de3817cb5470"
/>

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41705
- [x] Closes: #41892
- [x] Closes: #41872
- [x] Closes: #41545
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

### Changes

- **Bookmark Manager**  
  - Introduces a `BookmarkManager` class that:  
    - Holds bookmarks in memory and saves changes asynchronously.  
    - Is safe to operate from multiple threads.  
    - Uses immutable data for transport.  
    - Separates the **persistence model** from in-memory data.  
    - Assigns explicit unique IDs to bookmarks.  
- These IDs also serve as stable top-level command identifiers, enabling
aliases and shortcuts to be bound reliably.

- **Bookmark Resolver**  
- Determines the type of a bookmark (`CommandKind`: file, web link,
command, etc.).
  - Detects its target and parameters.  
- Returns a `Classification` object containing all information needed to
present the bookmark to the user (icon, primary command, context menu
actions, etc.).
- For unquoted local paths, attempts to find the *longest viable
matching path* to a file or executable, automatically handling spaces in
paths (e.g., `C:\Program Files`).
- The resolution of executables from the command line now more closely
matches **Windows Shell** behavior.
    - Users are more likely to get the correct result.  
    - Icons can be determined more reliably.  

- **Bookmark List Items**  
- Each top-level bookmark item (`BookmarkListItem`) is now responsible
for presenting itself.
  - Items refresh their state independently on load or after changes.  
  - The **Name** field is now optional.  
- If no explicit name is provided, a user-friendly fallback name is
computed automatically using the Shell API.
- Context actions are now more in line with **All Apps** and **Indexer**
built-in extensions, matching items, icons, and shortcuts (still a work
in progress).

- **Shell API Integration**  
- Uses the Shell API to provide friendly names and paths for shell or
file system items, keeping the UI aligned with the OS.

- **Protocol and Icon Support**  
  - Adds `IIconLocator` and protocol icon support.  
- Provides a custom icon for **Steam**, since Steam registers its
protocol to an executable not on the path (and the Steam protocol is
expected to be a common case).
  - Adds `FaviconLoader` for web links.  
- Can now follow redirects and retrieve the favicon even if the server
takes the request on a “sightseeing tour.”
- Provides **Fluent Segoe fallback icons** that match the bookmark
classification when no specific icon is available.

- **Refactors and Reorganization**  
  - Extracts `IPlaceholderParser` for testability and reusability.  
- Renames `Bookmarks` → `BookmarksData` to prevent naming collisions.
  - Reorganizes the structure (reducing root-level file clutter).  
  - Synchronizes icons and key chords with AllApps/Indexer.  
- Refactors placeholder parsing logic and **adds tests** to improve
reliability.

- **Misc**
- Correctly URL-encodes placeholder values in Web URL or protocol
bookmarks.

---

### Performance Improvements

- Eliminates full reloads of all bookmarks on every change.  
- Improves scalability when working with a large number of bookmarks.  
- Independent refresh of list items reduces UI lag and improves
responsiveness.
- Asynchronous persistence prevents blocking the UI thread on saves.  

---

### Breaking Changes

- **Placeholders**  
- Placeholder names are now restricted to letters (`a–z`, `A–Z`), digits
(`0–9`), uderscore (`_`), hyphen (`-`).
- GUIDs are explicitly excluded as valid placeholders to prevent
collisions with shell IDs.
- When presented to the user, placeholders are considered
case-insensitive.
- ** Bookmark Top-Level Command
- **Bookmark Top-Level Command**  
  - IDs for bookmark commands are now based on a unique identifier.  
  - This breaks existing bindings to shortcuts and aliases.  
- Newly created bindings will be stable regardless of changes to the
bookmark (name, address, or having placeholders).
  - 
<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Michael Jolley <mike@baldbeardedbuilder.com>
This commit is contained in:
Jiří Polášek
2025-10-01 23:45:01 +02:00
committed by GitHub
parent 0b9b91c060
commit 55f0bcc441
70 changed files with 6109 additions and 909 deletions

View File

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

View File

@@ -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).
/// </summary>
public partial class SupersedingAsyncGate : IDisposable
public sealed partial class SupersedingAsyncGate : IDisposable
{
private readonly Func<CancellationToken, Task> _action;
private readonly Lock _lock = new();

View File

@@ -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;
/// <summary>
/// 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 <see cref="Action{T}"/>).
/// 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.
/// </summary>
/// <typeparam name="T">The type of the computed value.</typeparam>
public sealed partial class SupersedingAsyncValueGate<T> : IDisposable
{
private readonly Func<CancellationToken, Task<T>> _valueFactory;
private readonly Action<T> _apply;
private readonly Lock _lock = new(); // Controls scheduling / superseding
private readonly Lock _applyLock = new(); // Serializes application of results
private int _callId;
private TaskCompletionSource<T>? _currentTcs;
private CancellationTokenSource? _currentCancellationSource;
private Task? _executingTask;
public SupersedingAsyncValueGate(
Func<CancellationToken, Task<T>> valueFactory,
Action<T> apply)
{
ArgumentNullException.ThrowIfNull(valueFactory);
ArgumentNullException.ThrowIfNull(apply);
_valueFactory = valueFactory;
_apply = apply;
}
/// <summary>
/// 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).
/// </summary>
/// <param name="cancellationToken">Optional external cancellation token.</param>
/// <returns>The computed value for this invocation.</returns>
public async Task<T> ExecuteAsync(CancellationToken cancellationToken = default)
{
TaskCompletionSource<T> 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<T>)state!).TrySetCanceled(cancellationToken), tcs);
return await tcs.Task.ConfigureAwait(false);
}
private async Task ExecuteLoop()
{
try
{
while (true)
{
TaskCompletionSource<T>? 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<T> 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<T> candidate,
int id,
Action<TaskCompletionSource<T>> 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<T>)));
_currentTcs = null;
}
GC.SuppressFinalize(this);
}
}

View File

@@ -114,7 +114,7 @@ public partial class App : Application
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
services.AddSingleton<ICommandProvider>(files);
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>();
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>(_ => BookmarksCommandProvider.CreateWithDefaultStore());
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>();

View File

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

View File

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

View File

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

View File

@@ -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<object[]> 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<object[]> 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<object[]> 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<object[]> 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)
]
];
}
}

View File

@@ -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<object[]> 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<object[]> 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)
];
}
}
}

View File

@@ -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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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<object[]> 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),
],
];
}
}

View File

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

View File

@@ -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 commandsAdd + 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<Pages.BookmarkListItem>().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);
}
}

View File

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

View File

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

View File

@@ -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<BookmarkData> _bookmarks;
public event Action<BookmarkData> BookmarkAdded;
public event Action<BookmarkData, BookmarkData> BookmarkUpdated;
public event Action<BookmarkData> BookmarkRemoved;
public IReadOnlyCollection<BookmarkData> 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<BookmarkData> bookmarks)
{
_bookmarks = [.. bookmarks];
}
}

View File

@@ -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<ArgumentNullException>(() => 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<PlaceholderInfo>(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)));
}
}

View File

@@ -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<object[]> 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<object[]> InvalidPlaceholderTestData =>
[
[string.Empty, false, string.Empty, Array.Empty<string>()],
["No placeholders here", false, "No placeholders here", Array.Empty<string>()],
["GUID: {550e8400-e29b-41d4-a716-446655440000}", false, "GUID: {550e8400-e29b-41d4-a716-446655440000}", Array.Empty<string>()],
["Invalid {user.name} placeholder", false, "Invalid {user.name} placeholder", Array.Empty<string>()],
["Empty {} placeholder", false, "Empty {} placeholder", Array.Empty<string>()],
["Unclosed {placeholder", false, "Unclosed {placeholder", Array.Empty<string>()],
["No opening brace placeholder}", false, "No opening brace placeholder}", Array.Empty<string>()],
["Invalid chars {user@domain}", false, "Invalid chars {user@domain}", Array.Empty<string>()],
["Spaces { name }", false, "Spaces { name }", Array.Empty<string>()]
];
[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<ArgumentNullException>(() => _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<ArgumentNullException>(() => new PlaceholderInfo(null!, 0));
}
[TestMethod]
public void Placeholder_Constructor_ThrowsArgumentOutOfRange()
{
// Assert
Assert.ThrowsException<ArgumentOutOfRangeException>(() => new PlaceholderInfo("Name", -1));
}
}

View File

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

View File

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

View File

@@ -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<char> 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 _));
}
}

View File

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

View File

@@ -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<string> _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();
}
}

View File

@@ -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<IconInfo> _icon;
private readonly FormContent _bookmarkPlaceholder;
public override IContent[] GetContent() => [_bookmarkPlaceholder];
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
public BookmarkPlaceholderPage(BookmarkData data)
: this(data.Name, data.Bookmark)
{
}
public BookmarkPlaceholderPage(string name, string url)
{
Name = Properties.Resources.bookmarks_command_name_open;
_bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url);
_icon = new Lazy<IconInfo>(() =>
{
ShellHelpers.ParseExecutableAndArgs(url, out var exe, out var args);
var t = UrlCommand.GetIconForPath(exe);
t.Wait();
return t.Result;
});
}
}

View File

@@ -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<CommandItem> _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<BookmarkListItem> _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);
SaveAndUpdateCommands();
var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser);
lock (_bookmarksLock)
{
_bookmarks.Add(newItem);
}
// In the edit path, `args` was already in _bookmarks, we just updated it
private void Edit_AddedCommand(object sender, BookmarkData args)
{
ExtensionHost.LogMessage($"Edited bookmark ({args.Name},{args.Bookmark})");
SaveAndUpdateCommands();
NotifyChange();
}
private void SaveAndUpdateCommands()
private void OnBookmarkRemoved(BookmarkData bookmarkData)
{
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<CommandItem> 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<CommandContextItem> 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();
}
return _commands.ToArray();
}
internal static string StateJsonPath()
if (Interlocked.CompareExchange(ref _loadState, LoadStateLoading, LoadStateNotLoaded) == LoadStateNotLoaded)
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
try
{
lock (_bookmarksLock)
{
_bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))];
_commands = BuildTopLevelCommandsUnsafe();
}
// now, the state is just next to the exe
return System.IO.Path.Combine(directory, "bookmarks.json");
Volatile.Write(ref _loadState, LoadStateLoaded);
RaiseItemsChanged();
}
catch
{
Volatile.Write(ref _loadState, LoadStateNotLoaded);
throw;
}
}
}
return _commands;
}
private void NotifyChange()
{
if (Volatile.Read(ref _loadState) != LoadStateLoaded)
{
return;
}
lock (_bookmarksLock)
{
_commands = BuildTopLevelCommandsUnsafe();
}
RaiseItemsChanged();
}
[Pure]
private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks];
}

View File

@@ -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<BookmarkData>? BookmarkAdded;
public event Action<BookmarkData, BookmarkData>? BookmarkUpdated; // old, new
public event Action<BookmarkData>? BookmarkRemoved;
public IReadOnlyCollection<BookmarkData> 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<BookmarkData> 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();
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using 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();
}
}

View File

@@ -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<string, string>? _placeholders;
private readonly IBookmarkResolver _bookmarkResolver;
private readonly SupersedingAsyncValueGate<IIconInfo?> _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<string, string>? 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);
}
}

View File

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

View File

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

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
/// <param name="id">Bookmark ID</param>
public static string GetLaunchBookmarkItemId(Guid id) => "Bookmarks.Launch." + id;
}

View File

@@ -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;
/// <summary>
/// Classifies a command or bookmark target type.
/// </summary>
public enum CommandKind
{
/// <summary>
/// Unknown or unsupported target.
/// </summary>
Unknown = 0,
/// <summary>
/// HTTP/HTTPS URL.
/// </summary>
WebUrl,
/// <summary>
/// Any non-file URI scheme (e.g., mailto:, ms-settings:, wt:, myapp:).
/// </summary>
Protocol,
/// <summary>
/// Application User Model ID (e.g., shell:AppsFolder\AUMID or pkgfamily!app).
/// </summary>
Aumid,
/// <summary>
/// Existing folder path.
/// </summary>
Directory,
/// <summary>
/// Existing executable file (e.g., .exe, .bat, .cmd).
/// </summary>
FileExecutable,
/// <summary>
/// Existing document file.
/// </summary>
FileDocument,
/// <summary>
/// Windows shortcut file (*.lnk).
/// </summary>
Shortcut,
/// <summary>
/// Internet shortcut file (*.url).
/// </summary>
InternetShortcut,
/// <summary>
/// Bare command resolved via PATH/PATHEXT (e.g., "wt", "git").
/// </summary>
PathCommand,
/// <summary>
/// Shell item not matching other types (e.g., Control Panel item, purely virtual directory).
/// </summary>
VirtualShellItem,
}

View File

@@ -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
{
/// <summary>
/// Launches the classified item.
/// </summary>
/// <param name="classification">Classification produced by CommandClassifier.</param>
/// <param name="runAsAdmin">Optional: force elevation if possible.</param>
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);
}
}
}

View File

@@ -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;
/// <summary>
/// Provides helper methods for parsing command lines and expanding paths.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
/// <summary>
/// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules
/// of CommandLineToArgvW.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
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);
}
/// <summary>
/// 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: ""
/// </summary>
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);
}
/// <summary>
/// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers.
/// </summary>
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);
}
}

View File

@@ -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 <folder/shell:uri>
ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app)
}

View File

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

View File

@@ -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;
/// <summary>
/// Helpers for getting user-friendly shell names and paths.
/// </summary>
internal static class ShellNames
{
/// <summary>
/// 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}"
/// </summary>
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);
}
}
}
/// <summary>
/// Optionally, also try to obtain a filesystem path (if the item represents one).
/// Returns false for purely virtual items like "This PC".
/// </summary>
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);
}
}
}
}

View File

@@ -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
{
/// <summary>
/// Tries to split a URI string into scheme and remainder.
/// Scheme must be valid per RFC 3986 and followed by ':'.
/// </summary>
public static bool TryGetScheme(ReadOnlySpan<char> 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;
}
}

View File

@@ -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<BookmarkData>? BookmarkAdded;
event Action<BookmarkData, BookmarkData>? BookmarkUpdated;
event Action<BookmarkData>? BookmarkRemoved;
IReadOnlyCollection<BookmarkData> Bookmarks { get; }
BookmarkData Add(string name, string bookmark);
bool Remove(Guid id);
BookmarkData? Update(Guid id, string name, string bookmark);
}

View File

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

View File

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

View File

@@ -10,13 +10,15 @@
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
<ProjectPriFileName>Microsoft.CmdPal.Ext.Bookmarks.pri</ProjectPriFileName>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\Bookmark.svg" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Assets\**\*.*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
@@ -26,14 +28,6 @@
</Compile>
</ItemGroup>
<ItemGroup>
<Content Update="Assets\Bookmark.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="Assets\Bookmark.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
@@ -41,4 +35,7 @@
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Bookmarks.UnitTests" />
</ItemGroup>
</Project>

View File

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

View File

@@ -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<object, BookmarkData>? AddedCommand;
private readonly BookmarkData? _bookmark;
internal event TypedEventHandler<object, BookmarkData>? 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();
}
}

View File

@@ -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<object, BookmarkData>? 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];
}

View File

@@ -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<BookmarkListItemReclassifyResult> _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<BookmarkListItemReclassifyResult>(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<BookmarkListItemReclassifyResult> ClassifyAsync(CancellationToken ct)
{
TypedEventHandler<object, BookmarkData> bookmarkSavedHandler = BookmarkSaved;
List<IContextItem> 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<IContextItem> 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<object, BookmarkData> bookmarkSavedHandler,
List<IContextItem> 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
);
}

View File

@@ -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<string, string>(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<string, string> 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;
}
}

View File

@@ -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<IIconInfo?> _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();
}

View File

@@ -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)
{
}
}

View File

@@ -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<Bookmarks>(json, BookmarkSerializationContext.Default.Bookmarks);
return bookmarks ?? new Bookmarks();
var bookmarks = JsonSerializer.Deserialize<BookmarksData>(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);
}
}

View File

@@ -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<BookmarkData>), 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;

View File

@@ -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<BookmarkData> Data { get; set; } = [];
}

View File

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

View File

@@ -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();

View File

@@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Failed to open {0}.
/// </summary>
public static string bookmark_toast_failed_open_text {
get {
return ResourceManager.GetString("bookmark_toast_failed_open_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add bookmark.
/// </summary>
@@ -87,6 +96,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copy address.
/// </summary>
public static string bookmarks_copy_address_name {
get {
return ResourceManager.GetString("bookmarks_copy_address_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Copy path.
/// </summary>
public static string bookmarks_copy_path_name {
get {
return ResourceManager.GetString("bookmarks_copy_path_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
@@ -96,6 +123,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete this bookmark?.
/// </summary>
public static string bookmarks_delete_prompt_message {
get {
return ResourceManager.GetString("bookmarks_delete_prompt_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete bookmark?.
/// </summary>
public static string bookmarks_delete_prompt_title {
get {
return ResourceManager.GetString("bookmarks_delete_prompt_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete bookmark.
/// </summary>
@@ -177,6 +222,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to (Refreshing bookmark...).
/// </summary>
public static string bookmarks_item_refreshing {
get {
return ResourceManager.GetString("bookmarks_item_refreshing", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open in Terminal.
/// </summary>
@@ -194,5 +248,14 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
return ResourceManager.GetString("bookmarks_required_placeholder", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unpin.
/// </summary>
public static string bookmarks_unpin_name {
get {
return ResourceManager.GetString("bookmarks_unpin_name", resourceCulture);
}
}
}
}

View File

@@ -164,4 +164,25 @@
<value>{0} is required</value>
<comment>{0} will be replaced by a parameter name provided by the user</comment>
</data>
<data name="bookmarks_item_refreshing" xml:space="preserve">
<value>(Refreshing bookmark...)</value>
</data>
<data name="bookmarks_delete_prompt_title" xml:space="preserve">
<value>Delete bookmark?</value>
</data>
<data name="bookmarks_delete_prompt_message" xml:space="preserve">
<value>Are you sure you want to delete this bookmark?</value>
</data>
<data name="bookmarks_copy_path_name" xml:space="preserve">
<value>Copy path</value>
</data>
<data name="bookmarks_copy_address_name" xml:space="preserve">
<value>Copy address</value>
</data>
<data name="bookmarks_unpin_name" xml:space="preserve">
<value>Unpin</value>
</data>
<data name="bookmark_toast_failed_open_text" xml:space="preserve">
<value>Failed to open {0}</value>
</data>
</root>

View File

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

View File

@@ -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<IRandomAccessStream?> 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<Uri?> 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<IRandomAccessStream?> 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);
}
}

View File

@@ -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<IIconInfo> GetIconForPath(Classification classification, CancellationToken cancellationToken = default);
}

View File

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

View File

@@ -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;
/// <summary>
/// Service to load favicons for websites.
/// </summary>
public interface IFaviconLoader
{
Task<IRandomAccessStream?> TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default);
}

View File

@@ -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<PlaceholderInfo> placeholders);
}

View File

@@ -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<IIconInfo> 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<IIconInfo?> 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<IIconInfo?> 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<IconInfo?> 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<IconInfo?> 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
{
/// <summary>
/// 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.
/// </summary>
public static string? GetIconString(string protocol)
{
try
{
if (string.IsNullOrWhiteSpace(protocol))
{
return null;
}
protocol = protocol.TrimEnd(':').ToLowerInvariant();
// Try HKCR\<protocol>\DefaultIcon
using (var di = Registry.ClassesRoot.OpenSubKey(protocol + "\\DefaultIcon"))
{
var value = di?.GetValue(null) as string;
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
// Fallback: HKCR\<protocol>\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;
}
}
}

View File

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

View File

@@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
public class PlaceholderInfoNameEqualityComparer : IEqualityComparer<PlaceholderInfo>
{
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);
}
}

View File

@@ -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<PlaceholderInfo> placeholders)
{
ArgumentNullException.ThrowIfNull(input);
head = string.Empty;
placeholders = [];
if (string.IsNullOrEmpty(input))
{
head = string.Empty;
return false;
}
var foundPlaceholders = new List<PlaceholderInfo>();
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 _);
}

View File

@@ -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<IconInfo> _icon;
public string Url { get; }
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
public UrlCommand(BookmarkData data)
: this(data.Name, data.Bookmark)
{
}
public UrlCommand(string name, string url)
{
Name = Properties.Resources.bookmarks_command_name_open;
Url = url;
_icon = new Lazy<IconInfo>(() =>
{
ShellHelpers.ParseExecutableAndArgs(Url, out var exe, out var args);
var t = GetIconForPath(exe);
t.Wait();
return t.Result;
});
}
public override CommandResult Invoke()
{
var 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<IconInfo> 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<IconInfo?> 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;
}
}

View File

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

View File

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

View File

@@ -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
{
/// <summary>
/// These are the executable file extensions that Windows Shell recognizes. Unlike CMD/PowerShell,
/// Shell does not use PATHEXT, but has a magic fixed list.
/// </summary>
public static string[] ExecutableExtensions { get; } = [".PIF", ".COM", ".EXE", ".BAT", ".CMD"];
/// <summary>
/// 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).
/// </summary>
/// <param name="fileName">The file name (with or without path) whose extension will be evaluated.</param>
/// <returns>
/// 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.
/// </returns>
public static bool IsExecutableFile(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
var fileExtension = Path.GetExtension(fileName);
return IsExecutableExtension(fileExtension);
}
/// <summary>
/// Determines whether the provided file extension (including the leading dot)
/// is one of the Windows Shell recognized executable extensions.
/// </summary>
/// <param name="fileExtension">The file extension to test. Should include the leading dot (e.g. ".exe").</param>
/// <returns>
/// 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.
/// </returns>
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
}
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;
}
}
/// <summary>
/// Mimics Windows Shell behavior to resolve an executable name to a full path.
/// </summary>
/// <param name="name"></param>
/// <param name="fullPath"></param>
/// <returns></returns>
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;
}
}

View File

@@ -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<IRandomAccessStream?> GetThumbnail(string path, bool jumbo = false)
public static async Task<IRandomAccessStream?> GetThumbnail(string path, bool jumbo = false)
{
var extension = Path.GetExtension(path).ToLower(CultureInfo.InvariantCulture);
var isImage = ImageExtensions.Contains(extension);
if (isImage)
{
try
{
return ImageExtensions.Contains(extension) ? GetImageThumbnailAsync(path) : GetFileIconStream(path, jumbo);
var result = await GetImageThumbnailAsync(path, jumbo);
if (result is not null)
{
return result;
}
}
catch (Exception)
{
// ignore and fall back to icon
}
}
return Task.FromResult<IRandomAccessStream?>(null);
try
{
return await GetFileIconStream(path, jumbo);
}
catch (Exception)
{
// ignore and return 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<IRandomAccessStream?> GetFileIconStream(string filePath, bool jumbo)
{
return await TryExtractUsingPIDL(filePath, jumbo)
?? await GetFileIconStreamUsingFilePath(filePath, jumbo);
}
private static async Task<IRandomAccessStream?> 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<IRandomAccessStream?> 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 await FromHIconToStream(hIcon);
}
return stream;
}
private static async Task<IRandomAccessStream?> GetImageThumbnailAsync(string filePath)
private static async Task<IRandomAccessStream?> 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;
}
/// <summary>
/// Get an icon stream for a registered URI protocol (e.g. "mailto:", "http:", "steam:").
/// </summary>
public static async Task<IRandomAccessStream?> 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<IRandomAccessStream?> 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 cant 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;
}
}