mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-05 18:57:19 +02:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
Reference in New Issue
Block a user