mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-07 19:09:43 +02:00
Compare commits
17 Commits
user/muyua
...
dev/mjolle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f2cabf47 | ||
|
|
5cce6abf20 | ||
|
|
e040096b59 | ||
|
|
eb4b41030d | ||
|
|
60b6bc8c34 | ||
|
|
c78723bfde | ||
|
|
3154e9114f | ||
|
|
ad69a9b18d | ||
|
|
8e95bd4d75 | ||
|
|
fd672b0460 | ||
|
|
3c37c311a1 | ||
|
|
17742ebc19 | ||
|
|
52f81b23d7 | ||
|
|
7f263e5a5e | ||
|
|
5bc35f911e | ||
|
|
6f10aadba2 | ||
|
|
ad06bde492 |
@@ -0,0 +1,187 @@
|
||||
// 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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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.CommandPalette.Extensions;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class BookmarkPathHandlingTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task Accented_NonAscii_Path_Is_Classified_As_Directory()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
var accentedName = "Éfolder";
|
||||
var dir = Path.Combine(tempRoot, accentedName);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
try
|
||||
{
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
var classification = await resolver.TryClassifyAsync(dir, CancellationToken.None);
|
||||
|
||||
Assert.IsTrue(classification.Success, "Classification should succeed for existing accented dir.");
|
||||
Assert.AreEqual(CommandKind.Directory, classification.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(dir), classification.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task Spaces_And_Punctuation_In_Path_Are_Handled()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
var folderName = "My Folder, (Test)";
|
||||
var dir = Path.Combine(tempRoot, folderName);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
try
|
||||
{
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
var classification = await resolver.TryClassifyAsync(dir, CancellationToken.None);
|
||||
|
||||
Assert.IsTrue(classification.Success, "Classification should succeed for existing folder with spaces/punctuation.");
|
||||
Assert.AreEqual(CommandKind.Directory, classification.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(dir), classification.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Also verify that percent-encoded input for the same folder (conservative decoding) resolves to same directory if decoder present
|
||||
var encoded = Path.Combine(Path.GetDirectoryName(dir) ?? string.Empty, folderName.Replace(" ", "%20"));
|
||||
var classificationEncoded = await resolver.TryClassifyAsync(encoded, CancellationToken.None);
|
||||
|
||||
// either decoding is supported and classification succeeds, or it remains unknown; in either case, do not start external processes in this test
|
||||
if (classificationEncoded.Success)
|
||||
{
|
||||
Assert.AreEqual(CommandKind.Directory, classificationEncoded.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(dir), classificationEncoded.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PercentEncoded_Accented_Input_Decodes_To_Existing_Accented_Path_When_Present()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
var accentedName = "Éfolder";
|
||||
var dir = Path.Combine(tempRoot, accentedName);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
try
|
||||
{
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
|
||||
// Percent-encode the UTF8 bytes of the accented name (capital E with acute)
|
||||
var utf8 = System.Text.Encoding.UTF8.GetBytes(accentedName);
|
||||
var encodedName = string.Concat(Array.ConvertAll(utf8, b => $"%{b:X2}"));
|
||||
|
||||
var encodedPath = Path.Combine(tempRoot, encodedName);
|
||||
var classification = await resolver.TryClassifyAsync(encodedPath, CancellationToken.None);
|
||||
|
||||
// Conservative behavior: if resolver decodes percent-encoding it should resolve to the real folder
|
||||
if (classification.Success)
|
||||
{
|
||||
Assert.AreEqual(CommandKind.Directory, classification.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(dir), classification.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task LaunchBookmark_FallsBack_To_Nearest_Existing_Parent_Directory()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
var parent = Path.Combine(tempRoot, "parent");
|
||||
Directory.CreateDirectory(parent);
|
||||
|
||||
var child = Path.Combine(parent, "child", "file.txt"); // child does not exist
|
||||
|
||||
try
|
||||
{
|
||||
var bookmark = new BookmarkData("TestBookmark", child);
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
var classification = resolver.ClassifyOrUnknown(bookmark.Bookmark);
|
||||
|
||||
// Use a mock process launcher to capture what gets launched
|
||||
var mockLauncher = new MockProcessLauncher();
|
||||
var launchCmd = new LaunchBookmarkCommand(bookmark, classification, new TestBookmarkIconLocator(), resolver, mockLauncher);
|
||||
var result = launchCmd.Invoke(sender: this);
|
||||
|
||||
// Should have launched a classification targeting the existing parent directory
|
||||
Assert.IsNotNull(mockLauncher.LastLaunchedClassification, "Expected a launch to be captured");
|
||||
Assert.AreEqual(CommandKind.Directory, mockLauncher.LastLaunchedClassification.Kind);
|
||||
Assert.IsTrue(Directory.Exists(parent));
|
||||
Assert.AreEqual(Path.GetFullPath(parent), mockLauncher.LastLaunchedClassification.Target, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MockProcessLauncher : IProcessLauncher
|
||||
{
|
||||
public Classification? LastLaunchedClassification { get; private set; }
|
||||
|
||||
public bool Launch(Classification classification, bool runAsAdmin = false)
|
||||
{
|
||||
LastLaunchedClassification = classification;
|
||||
|
||||
// Mirror the real launcher: ShellExecute fails for a filesystem target that
|
||||
// doesn't exist, and that failure is what drives the nearest-existing-parent
|
||||
// fallback. Existing targets (e.g. the parent directory) "launch" successfully.
|
||||
return Directory.Exists(classification.Target) || File.Exists(classification.Target);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestBookmarkIconLocator : IBookmarkIconLocator
|
||||
{
|
||||
public Task<IIconInfo> GetIconForPath(Classification classification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Return some default icon quickly; tests don't rely on icon semantics
|
||||
return Task.FromResult<IIconInfo>(Icons.Reloading);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
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;
|
||||
|
||||
[TestClass]
|
||||
public class BookmarkResolverGreedyFindTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task Resolver_Handles_Different_Unicode_Normalization_Forms()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
var nfcName = "\u00C9folder"; // Éfolder (precomposed)
|
||||
var nfdName = "E\u0301folder"; // E + combining acute
|
||||
|
||||
var dirNfc = Path.Combine(tempRoot, nfcName);
|
||||
Directory.CreateDirectory(dirNfc);
|
||||
|
||||
try
|
||||
{
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
|
||||
var inputNfc = dirNfc;
|
||||
var cNfc = await resolver.TryClassifyAsync(inputNfc, CancellationToken.None);
|
||||
Assert.IsTrue(cNfc.Success);
|
||||
Assert.AreEqual(CommandKind.Directory, cNfc.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(dirNfc), cNfc.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var inputNfd = Path.Combine(tempRoot, nfdName);
|
||||
var cNfd = await resolver.TryClassifyAsync(inputNfd, CancellationToken.None);
|
||||
Assert.IsTrue(cNfd.Success, "Resolver should succeed for NFD-encoded path if equivalent NFC exists on disk");
|
||||
Assert.AreEqual(CommandKind.Directory, cNfd.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(dirNfc), cNfd.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task GreedyFind_Resolves_With_Mixed_Separators_And_Forward_Slashes()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
|
||||
var a = Path.Combine(tempRoot, "A");
|
||||
var b = Path.Combine(a, "B");
|
||||
Directory.CreateDirectory(b);
|
||||
|
||||
var file = Path.Combine(b, "file.txt");
|
||||
File.WriteAllText(file, "hello");
|
||||
|
||||
try
|
||||
{
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
|
||||
// Mixed separators
|
||||
var mixed = tempRoot + "\\A/B\\file.txt extra args";
|
||||
var rMixed = await resolver.TryClassifyAsync(mixed, CancellationToken.None);
|
||||
Assert.IsTrue(rMixed.Success);
|
||||
Assert.AreEqual(CommandKind.FileDocument, rMixed.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(file), rMixed.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Forward slashes only
|
||||
var forward = tempRoot + "/A/B/file.txt";
|
||||
var rForward = await resolver.TryClassifyAsync(forward, CancellationToken.None);
|
||||
Assert.IsTrue(rForward.Success);
|
||||
Assert.AreEqual(CommandKind.FileDocument, rForward.Result.Kind);
|
||||
Assert.AreEqual(Path.GetFullPath(file), rForward.Result.Target, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,14 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
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.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
@@ -129,4 +134,73 @@ public class BookmarksCommandProviderTests
|
||||
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
|
||||
Assert.IsNotNull(addCommand);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(5000)]
|
||||
public async Task ProviderPassesResolverClassificationToLauncher_Unchanged()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkAddress = @"C:\Données\été\résumé.txt";
|
||||
var bookmark = new BookmarkData("Accented bookmark", bookmarkAddress);
|
||||
var mockBookmarkManager = new MockBookmarkManager(bookmark);
|
||||
var expectedClassification = new Classification(
|
||||
CommandKind.FileDocument,
|
||||
bookmarkAddress,
|
||||
bookmarkAddress,
|
||||
string.Empty,
|
||||
LaunchMethod.ShellExecute,
|
||||
@"C:\Données\été",
|
||||
false);
|
||||
var resolver = new TestBookmarkResolver(expectedClassification);
|
||||
var mockLauncher = new MockProcessLauncher();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager, resolver, processLauncher: mockLauncher);
|
||||
|
||||
var commands = provider.TopLevelCommands();
|
||||
var bookmarkItem = commands.OfType<Pages.BookmarkListItem>().Single();
|
||||
await bookmarkItem.IsInitialized;
|
||||
var launchCommand = bookmarkItem.Command as LaunchBookmarkCommand;
|
||||
Assert.IsNotNull(launchCommand);
|
||||
|
||||
// Act — invoke the command; the mock launcher captures what's launched
|
||||
_ = launchCommand.Invoke(this);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(bookmarkAddress, resolver.LastClassifyInput);
|
||||
Assert.IsNotNull(mockLauncher.LastLaunchedClassification);
|
||||
Assert.AreEqual(expectedClassification.Target, mockLauncher.LastLaunchedClassification.Target);
|
||||
Assert.AreEqual(expectedClassification.Kind, mockLauncher.LastLaunchedClassification.Kind);
|
||||
Assert.AreEqual(expectedClassification.Launch, mockLauncher.LastLaunchedClassification.Launch);
|
||||
}
|
||||
|
||||
private sealed class MockProcessLauncher : IProcessLauncher
|
||||
{
|
||||
public Classification? LastLaunchedClassification { get; private set; }
|
||||
|
||||
public bool Launch(Classification classification, bool runAsAdmin = false)
|
||||
{
|
||||
LastLaunchedClassification = classification;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestBookmarkResolver : IBookmarkResolver
|
||||
{
|
||||
private readonly Classification _classification;
|
||||
|
||||
internal string? LastClassifyInput { get; private set; }
|
||||
|
||||
internal TestBookmarkResolver(Classification classification)
|
||||
{
|
||||
_classification = classification;
|
||||
}
|
||||
|
||||
public Task<(bool Success, Classification Result)> TryClassifyAsync(string input, System.Threading.CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult((true, _classification));
|
||||
|
||||
public Classification ClassifyOrUnknown(string input)
|
||||
{
|
||||
LastClassifyInput = input;
|
||||
return _classification;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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.Text;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PathNormalization.NormalizePathForWindows(string)"/>.
|
||||
/// <para>
|
||||
/// Long-path / NT-object prefix stripping is Windows-only behavior, so the
|
||||
/// prefix-handling tests assert Windows expectations and use
|
||||
/// <see cref="Assert.Inconclusive(string)"/> on non-Windows agents so CI on
|
||||
/// Linux/macOS skips them cleanly without reporting failures.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cross-platform behavior (NFC, trailing whitespace) is asserted on every OS.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class PathNormalizationTests
|
||||
{
|
||||
private static void RequireWindows()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
Assert.Inconclusive("Windows-only behavior; skipped on non-Windows CI agents.");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Null_Or_Empty_Is_Returned_Unchanged()
|
||||
{
|
||||
Assert.AreEqual(string.Empty, PathNormalization.NormalizePathForWindows(string.Empty));
|
||||
Assert.AreEqual(null, PathNormalization.NormalizePathForWindows(null!));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Ascii_Path_Is_Unchanged()
|
||||
{
|
||||
const string path = @"C:\Users\Public\Documents\readme.txt";
|
||||
Assert.AreEqual(path, PathNormalization.NormalizePathForWindows(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Decomposed_Accents_Are_Normalized_To_NFC()
|
||||
{
|
||||
// "ÉCOLES" composed two ways: precomposed (NFC) vs decomposed (NFD).
|
||||
var nfc = "\u00C9COLES"; // É (U+00C9) + COLES
|
||||
var nfd = "E\u0301COLES"; // E + COMBINING ACUTE ACCENT (U+0301) + COLES
|
||||
|
||||
Assert.AreNotEqual(nfc, nfd, "Sanity: the two encodings differ before normalization.");
|
||||
|
||||
var normalized = PathNormalization.NormalizePathForWindows(nfd);
|
||||
|
||||
Assert.AreEqual(nfc, normalized);
|
||||
Assert.IsTrue(normalized.IsNormalized(NormalizationForm.FormC));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NonAscii_Characters_Are_Preserved_Without_Loss()
|
||||
{
|
||||
// Mix of accented Latin, CJK, emoji (surrogate pair), and a Cyrillic letter.
|
||||
const string original = "C:\\données\\café\\日本語\\🚀\\Привет.txt";
|
||||
|
||||
var normalized = PathNormalization.NormalizePathForWindows(original);
|
||||
|
||||
Assert.AreEqual(original.Normalize(NormalizationForm.FormC), normalized);
|
||||
StringAssert.Contains(normalized, "données");
|
||||
StringAssert.Contains(normalized, "café");
|
||||
StringAssert.Contains(normalized, "日本語");
|
||||
StringAssert.Contains(normalized, "🚀");
|
||||
StringAssert.Contains(normalized, "Привет");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Trailing_Ascii_Whitespace_Is_Trimmed()
|
||||
{
|
||||
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows("C:\\foo\\bar.txt "));
|
||||
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows("C:\\foo\\bar.txt\t"));
|
||||
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows("C:\\foo\\bar.txt\r\n"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Trailing_NonBreaking_Space_Is_Trimmed()
|
||||
{
|
||||
// U+00A0 NO-BREAK SPACE — frequently introduced by copy/paste from web pages.
|
||||
var input = "C:\\foo\\bar.txt\u00A0";
|
||||
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Internal_Whitespace_Is_Preserved()
|
||||
{
|
||||
const string input = @"C:\Program Files\My App\file name.txt";
|
||||
Assert.AreEqual(input, PathNormalization.NormalizePathForWindows(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Trailing_Dots_Are_Preserved()
|
||||
{
|
||||
// Trimming trailing dots could be lossy (e.g., "foo..." vs "foo").
|
||||
// We deliberately keep them; Windows callers can decide what to do.
|
||||
const string input = @"C:\foo\bar...";
|
||||
Assert.AreEqual(input, PathNormalization.NormalizePathForWindows(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LongPath_Prefix_Is_Stripped_On_Windows()
|
||||
{
|
||||
RequireWindows();
|
||||
|
||||
Assert.AreEqual(
|
||||
@"C:\very\long\path\file.txt",
|
||||
PathNormalization.NormalizePathForWindows(@"\\?\C:\very\long\path\file.txt"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LongUnc_Prefix_Is_Rewritten_To_Unc_On_Windows()
|
||||
{
|
||||
RequireWindows();
|
||||
|
||||
Assert.AreEqual(
|
||||
@"\\server\share\file.txt",
|
||||
PathNormalization.NormalizePathForWindows(@"\\?\UNC\server\share\file.txt"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NtObject_Prefix_Is_Stripped_On_Windows()
|
||||
{
|
||||
RequireWindows();
|
||||
|
||||
Assert.AreEqual(
|
||||
@"C:\Windows\System32\notepad.exe",
|
||||
PathNormalization.NormalizePathForWindows(@"\??\C:\Windows\System32\notepad.exe"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Unc_Without_LongPath_Prefix_Is_Untouched_On_Windows()
|
||||
{
|
||||
RequireWindows();
|
||||
|
||||
const string input = @"\\server\share\file.txt";
|
||||
Assert.AreEqual(input, PathNormalization.NormalizePathForWindows(input));
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,11 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
private const int LoadStateLoading = 1;
|
||||
private const int LoadStateLoaded = 2;
|
||||
|
||||
private readonly IPlaceholderParser _placeholderParser = new PlaceholderParser();
|
||||
private readonly IPlaceholderParser _placeholderParser;
|
||||
private readonly IBookmarksManager _bookmarksManager;
|
||||
private readonly IBookmarkResolver _commandResolver;
|
||||
private readonly IBookmarkIconLocator _iconLocator = new IconLocator();
|
||||
private readonly IProcessLauncher _processLauncher;
|
||||
|
||||
public IBookmarksManager BookmarksManager => _bookmarksManager;
|
||||
|
||||
@@ -46,14 +47,16 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
return new BookmarksCommandProvider(new BookmarksManager(new FileBookmarkDataSource(StateJsonPath())));
|
||||
}
|
||||
|
||||
internal BookmarksCommandProvider(IBookmarksManager bookmarksManager)
|
||||
internal BookmarksCommandProvider(IBookmarksManager bookmarksManager, IBookmarkResolver? commandResolver = null, IPlaceholderParser? placeholderParser = null, IProcessLauncher? processLauncher = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
_placeholderParser = placeholderParser ?? new PlaceholderParser();
|
||||
_bookmarksManager = bookmarksManager;
|
||||
_bookmarksManager.BookmarkAdded += OnBookmarkAdded;
|
||||
_bookmarksManager.BookmarkRemoved += OnBookmarkRemoved;
|
||||
|
||||
_commandResolver = new BookmarkResolver(_placeholderParser);
|
||||
_commandResolver = commandResolver ?? new BookmarkResolver(_placeholderParser);
|
||||
_processLauncher = processLauncher ?? new ProductionProcessLauncher();
|
||||
|
||||
Id = "Bookmarks";
|
||||
DisplayName = Resources.bookmarks_display_name;
|
||||
@@ -66,7 +69,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
|
||||
private void OnBookmarkAdded(BookmarkData bookmarkData)
|
||||
{
|
||||
var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser);
|
||||
var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser, _processLauncher);
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_bookmarks.Add(newItem);
|
||||
@@ -95,7 +98,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
{
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))];
|
||||
_bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser, _processLauncher))];
|
||||
_commands = BuildTopLevelCommandsUnsafe();
|
||||
}
|
||||
|
||||
@@ -151,6 +154,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
_commandResolver,
|
||||
_iconLocator,
|
||||
_placeholderParser,
|
||||
_processLauncher,
|
||||
asBand: true))];
|
||||
}
|
||||
|
||||
@@ -200,6 +204,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
_commandResolver,
|
||||
_iconLocator,
|
||||
_placeholderParser,
|
||||
_processLauncher,
|
||||
asBand: true);
|
||||
|
||||
return new WrappedDockItem(items: [listItem], id: id, displayTitle: listItem.BookmarkTitle);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
@@ -21,6 +23,7 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
|
||||
private readonly IBookmarkResolver _bookmarkResolver;
|
||||
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
|
||||
private readonly Classification _classification;
|
||||
private readonly IProcessLauncher _processLauncher;
|
||||
|
||||
private IIconInfo? _icon;
|
||||
|
||||
@@ -30,7 +33,7 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, Dictionary<string, string>? placeholders = null)
|
||||
public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, IProcessLauncher? processLauncher = null, Dictionary<string, string>? placeholders = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmarkData);
|
||||
ArgumentNullException.ThrowIfNull(classification);
|
||||
@@ -39,6 +42,7 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
|
||||
_classification = classification;
|
||||
_placeholders = placeholders;
|
||||
_bookmarkResolver = bookmarkResolver;
|
||||
_processLauncher = processLauncher ?? new ProductionProcessLauncher();
|
||||
|
||||
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
|
||||
Name = Resources.bookmarks_command_name_open;
|
||||
@@ -64,9 +68,16 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
|
||||
public ICommandResult Invoke(object sender)
|
||||
{
|
||||
var bookmarkAddress = ReplacePlaceholders(_bookmarkData.Bookmark);
|
||||
var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress);
|
||||
|
||||
var success = CommandLauncher.Launch(classification);
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress);
|
||||
success = LaunchWithFilesystemFallback(classification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to launch bookmark '{bookmarkAddress}'", ex);
|
||||
}
|
||||
|
||||
return success
|
||||
? CommandResult.Dismiss()
|
||||
@@ -79,6 +90,66 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
|
||||
});
|
||||
}
|
||||
|
||||
private bool LaunchWithFilesystemFallback(Classification classification)
|
||||
{
|
||||
if (TryLaunch(classification))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!IsFilesystemFallbackCandidate(classification))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetNearestExistingParentDirectory(classification, out var parentDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fallbackClassification = classification with
|
||||
{
|
||||
Kind = CommandKind.Directory,
|
||||
Target = parentDirectory,
|
||||
Arguments = string.Empty,
|
||||
Launch = LaunchMethod.ExplorerOpen,
|
||||
WorkingDirectory = parentDirectory,
|
||||
FileSystemTarget = parentDirectory,
|
||||
};
|
||||
|
||||
return TryLaunch(fallbackClassification);
|
||||
}
|
||||
|
||||
private bool TryLaunch(Classification classification)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _processLauncher.Launch(classification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to launch bookmark target '{classification.Target}'", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFilesystemFallbackCandidate(Classification classification) =>
|
||||
classification.Kind switch
|
||||
{
|
||||
CommandKind.Directory => true,
|
||||
CommandKind.FileDocument => true,
|
||||
CommandKind.Shortcut => true,
|
||||
CommandKind.InternetShortcut => true,
|
||||
CommandKind.VirtualShellItem => !string.IsNullOrWhiteSpace(classification.FileSystemTarget),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static bool TryGetNearestExistingParentDirectory(Classification classification, out string parentDirectory)
|
||||
{
|
||||
// Delegate to shared helper which applies normalization and consistent probing
|
||||
return PathHelpers.TryGetNearestExistingParentDirectory(classification, out parentDirectory);
|
||||
}
|
||||
|
||||
private string ReplacePlaceholders(string input)
|
||||
{
|
||||
var result = input;
|
||||
|
||||
@@ -17,8 +17,8 @@ internal static class CommandLauncher
|
||||
/// <param name="runAsAdmin">Optional: force elevation if possible.</param>
|
||||
public static bool Launch(Classification classification, bool runAsAdmin = false)
|
||||
{
|
||||
switch (classification.Launch)
|
||||
{
|
||||
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:
|
||||
|
||||
@@ -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.Helpers;
|
||||
|
||||
internal interface IProcessLauncher
|
||||
{
|
||||
bool Launch(Classification classification, bool runAsAdmin = false);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 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 sealed class ProductionProcessLauncher : IProcessLauncher
|
||||
{
|
||||
public bool Launch(Classification classification, bool runAsAdmin = false)
|
||||
{
|
||||
return CommandLauncher.Launch(classification, runAsAdmin);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
private readonly IBookmarkResolver _commandResolver;
|
||||
private readonly IBookmarkIconLocator _iconLocator;
|
||||
private readonly IPlaceholderParser _placeholderParser;
|
||||
private readonly IProcessLauncher _processLauncher;
|
||||
private readonly SupersedingAsyncValueGate<BookmarkListItemReclassifyResult> _classificationGate;
|
||||
private readonly TaskCompletionSource _initializationTcs = new();
|
||||
private readonly bool _isBandItem;
|
||||
@@ -44,11 +45,13 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
IBookmarkResolver commandResolver,
|
||||
IBookmarkIconLocator iconLocator,
|
||||
IPlaceholderParser placeholderParser,
|
||||
IProcessLauncher processLauncher,
|
||||
bool asBand = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmark);
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
ArgumentNullException.ThrowIfNull(commandResolver);
|
||||
ArgumentNullException.ThrowIfNull(processLauncher);
|
||||
_isBandItem = asBand;
|
||||
_bookmark = bookmark;
|
||||
_bookmarksManager = bookmarksManager;
|
||||
@@ -56,6 +59,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
_commandResolver = commandResolver;
|
||||
_iconLocator = iconLocator;
|
||||
_placeholderParser = placeholderParser;
|
||||
_processLauncher = processLauncher;
|
||||
_classificationGate = new SupersedingAsyncValueGate<BookmarkListItemReclassifyResult>(ClassifyAsync, ApplyClassificationResult);
|
||||
_ = _classificationGate.ExecuteAsync();
|
||||
}
|
||||
@@ -109,7 +113,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
|
||||
ICommand command = classification.IsPlaceholder
|
||||
? new BookmarkPlaceholderPage(_bookmark, _iconLocator, _commandResolver, _placeholderParser)
|
||||
: new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver);
|
||||
: new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver, _processLauncher);
|
||||
|
||||
BuildSpecificContextMenuItems(classification, contextMenu);
|
||||
AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu);
|
||||
|
||||
@@ -215,14 +215,19 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
var (firstHead, tail) = SplitHeadAndArgs(input);
|
||||
CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head);
|
||||
|
||||
// Normalize a copy for path-detection/probing only. We must not overwrite 'head'
|
||||
// itself, because normalization trims trailing whitespace and would turn a meaningful
|
||||
// token (e.g. a quoted single space) into an empty string for the fallback target below.
|
||||
var normalizedHead = NormalizePathForWindows(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))
|
||||
if (TryGetAumid(normalizedHead, out var aumid2))
|
||||
{
|
||||
result = new Classification(
|
||||
CommandKind.Aumid,
|
||||
head,
|
||||
normalizedHead,
|
||||
aumid2,
|
||||
tail,
|
||||
LaunchMethod.ActivateAppId,
|
||||
@@ -234,7 +239,7 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
|
||||
// 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))
|
||||
if (CommandLineHelper.HasShellPrefix(normalizedHead))
|
||||
{
|
||||
ShellNames.TryGetFriendlyName(input, out var displayName);
|
||||
ShellNames.TryGetFileSystemPath(input, out var fsPath);
|
||||
@@ -254,7 +259,7 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
// 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))
|
||||
if (TryResolveViaPath(normalizedHead, out var resolvedFilePath))
|
||||
{
|
||||
result = new Classification(
|
||||
CommandKind.PathCommand,
|
||||
@@ -269,9 +274,9 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
}
|
||||
|
||||
// 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))
|
||||
if (LooksPathy(normalizedHead) && Path.HasExtension(normalizedHead))
|
||||
{
|
||||
var extension = Path.GetExtension(head);
|
||||
var extension = Path.GetExtension(normalizedHead);
|
||||
|
||||
// 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('{');
|
||||
@@ -280,10 +285,10 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
result = new Classification(
|
||||
ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument,
|
||||
input,
|
||||
head,
|
||||
normalizedHead,
|
||||
tail,
|
||||
LaunchMethod.ShellExecute,
|
||||
HasDir(head) ? Path.GetDirectoryName(head) : null,
|
||||
HasDir(normalizedHead) ? Path.GetDirectoryName(normalizedHead) : null,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
@@ -291,7 +296,7 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
}
|
||||
|
||||
// 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))
|
||||
if (normalizedHead.Contains('.', StringComparison.OrdinalIgnoreCase) && normalizedHead.StartsWith("www", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// treat as URL, add https://
|
||||
var url = "https://" + input;
|
||||
@@ -329,8 +334,6 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
{
|
||||
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:
|
||||
@@ -345,7 +348,11 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
|
||||
private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
// Be greedy: try to find the longest existing path prefix
|
||||
// Be greedy: try to find the longest existing path prefix.
|
||||
// Only probe at whitespace boundaries to find where the command ends and arguments begin.
|
||||
// We intentionally do NOT split at path separators — that would act as parent-directory
|
||||
// fallback, misclassifying non-existing files (e.g. classifying C:\Users\x\file.txt as
|
||||
// Directory because C:\Users exists).
|
||||
for (var i = input.Length; i >= 0; i--)
|
||||
{
|
||||
if (i < input.Length && !char.IsWhiteSpace(input[i]))
|
||||
@@ -353,29 +360,24 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = input.AsSpan(0, i).TrimEnd().ToString();
|
||||
var candidate = input.AsSpan(0, i).TrimEnd();
|
||||
if (candidate.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateStr = candidate.ToString();
|
||||
|
||||
// If we have placeholders, check if this candidate would contain a non-path placeholder
|
||||
if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser))
|
||||
if (containsPlaceholders && ContainsNonPathPlaceholder(candidateStr, placeholderParser))
|
||||
{
|
||||
continue; // Skip this candidate, try a shorter one
|
||||
}
|
||||
|
||||
try
|
||||
if (TryResolvePathCandidate(candidateStr, out var resolvedCandidate))
|
||||
{
|
||||
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
|
||||
var tail = i < input.Length ? input[i..].TrimStart() : string.Empty;
|
||||
return (resolvedCandidate, tail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,11 +527,109 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile);
|
||||
}
|
||||
|
||||
private static bool TryResolvePathCandidate(string candidate, out string resolvedPath)
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
|
||||
var normalizedCandidate = NormalizePathForWindows(candidate);
|
||||
|
||||
if (TryResolvePathCandidateCore(normalizedCandidate, out resolvedPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryPercentDecodePathCandidate(normalizedCandidate, out var decodedCandidate)
|
||||
&& !decodedCandidate.Equals(normalizedCandidate, StringComparison.Ordinal)
|
||||
&& TryResolvePathCandidateCore(decodedCandidate, out resolvedPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryResolvePathCandidateCore(string candidate, out string resolvedPath)
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var normalizedCandidate = NormalizePathForWindows(candidate);
|
||||
if (CommandLineHelper.ExpandPathToPhysicalFile(normalizedCandidate, true, out var expanded))
|
||||
{
|
||||
var normalizedExpanded = NormalizePathForWindows(expanded);
|
||||
if (File.Exists(normalizedExpanded) || Directory.Exists(normalizedExpanded))
|
||||
{
|
||||
resolvedPath = normalizedExpanded;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to resolve path candidate '{candidate}'", ex);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryPercentDecodePathCandidate(string input, out string decoded)
|
||||
{
|
||||
decoded = input;
|
||||
if (!ContainsPercentEncodingSequence(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
decoded = NormalizePathForWindows(Uri.UnescapeDataString(input));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to percent-decode path candidate '{input}'", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ContainsPercentEncodingSequence(string input)
|
||||
{
|
||||
for (var i = 0; i + 2 < input.Length; i++)
|
||||
{
|
||||
if (input[i] == '%'
|
||||
&& IsHexDigit(input[i + 1])
|
||||
&& IsHexDigit(input[i + 2]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsHexDigit(char c) =>
|
||||
(c >= '0' && c <= '9')
|
||||
|| (c >= 'a' && c <= 'f')
|
||||
|| (c >= 'A' && c <= 'F');
|
||||
|
||||
private static bool TryGetNearestExistingParentDirectory(string path, out string parentDirectory)
|
||||
{
|
||||
// Delegate to shared helper to avoid duplicated logic across the extension
|
||||
return PathHelpers.TryGetNearestExistingParentDirectory(path, out parentDirectory);
|
||||
}
|
||||
|
||||
private static string NormalizePathForWindows(string path) =>
|
||||
PathNormalization.NormalizePathForWindows(path);
|
||||
|
||||
private static string? TryProbe(string? dir, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = dir is null ? name : Path.Combine(dir, name);
|
||||
|
||||
// Normalize before probing filesystem
|
||||
path = NormalizePathForWindows(path);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
internal static class PathHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the nearest existing parent directory for a given path string.
|
||||
/// Normalizes the input (NFC) before probing and trims long-path prefixes on Windows.
|
||||
/// </summary>
|
||||
public static bool TryGetNearestExistingParentDirectory(string path, out string parentDirectory)
|
||||
{
|
||||
parentDirectory = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = PathNormalization.NormalizePathForWindows(path)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
while (!string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
if (Directory.Exists(current))
|
||||
{
|
||||
parentDirectory = PathNormalization.NormalizePathForWindows(Path.GetFullPath(current));
|
||||
return true;
|
||||
}
|
||||
|
||||
var next = Path.GetDirectoryName(current);
|
||||
if (string.IsNullOrEmpty(next) || next.Equals(current, StringComparison.Ordinal))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to resolve nearest parent directory for '{path}'", ex);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload that accepts a Classification and probes classification.FileSystemTarget if present,
|
||||
/// otherwise classification.Target.
|
||||
/// </summary>
|
||||
public static bool TryGetNearestExistingParentDirectory(Classification classification, out string parentDirectory)
|
||||
{
|
||||
parentDirectory = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var pathToProbe = string.IsNullOrWhiteSpace(classification.FileSystemTarget) ? classification.Target : classification.FileSystemTarget;
|
||||
if (string.IsNullOrWhiteSpace(pathToProbe))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryGetNearestExistingParentDirectory(pathToProbe, out parentDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to resolve fallback folder for '{classification.Target}'", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Conservative, lossless normalization helpers for filesystem paths used by the
|
||||
/// Bookmark resolver when probing on Windows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// On Windows, the goal is to give file/directory probes a fair chance at hitting
|
||||
/// an existing target without ever mangling the user's data. We:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Apply Unicode Normalization Form C (NFC) so visually equivalent
|
||||
/// accented sequences (e.g. precomposed "É" vs. "E + COMBINING ACUTE ACCENT")
|
||||
/// compare equal to the way Windows tools typically write them to NTFS.</description></item>
|
||||
/// <item><description>Strip well-known long-path / NT-object prefixes (<c>\\?\</c>,
|
||||
/// <c>\\?\UNC\</c>, <c>\??\</c>) before probing because <see cref="System.IO.File.Exists(string)"/>
|
||||
/// and friends accept the un-prefixed form and many user-entered paths do not include the prefix.</description></item>
|
||||
/// <item><description>Trim trailing ASCII whitespace from the whole path because Win32
|
||||
/// ignores trailing spaces in filenames anyway and copy/paste from the web frequently
|
||||
/// leaves a stray space or NBSP behind. Trailing dots are deliberately preserved
|
||||
/// because removing them can be lossy.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// All transformations are length-preserving for the meaningful portion of the
|
||||
/// string and never drop or substitute non-ASCII characters. On non-Windows
|
||||
/// platforms only NFC normalization is applied.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PathNormalization
|
||||
{
|
||||
private const string LongPathPrefix = @"\\?\";
|
||||
private const string LongUncPrefix = @"\\?\UNC\";
|
||||
private const string NtObjectPrefix = @"\??\";
|
||||
|
||||
/// <summary>
|
||||
/// Returns a normalized form of <paramref name="path"/> that is safe to feed into
|
||||
/// .NET filesystem probes (<see cref="System.IO.File.Exists(string)"/>,
|
||||
/// <see cref="System.IO.Directory.Exists(string)"/>, <see cref="System.IO.Path.GetFullPath(string)"/>).
|
||||
/// The function is conservative: it never drops or substitutes non-ASCII characters
|
||||
/// and returns the input unchanged when no normalization is needed.
|
||||
/// </summary>
|
||||
public static string NormalizePathForWindows(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
// NFC is safe everywhere. It compares equal byte-for-byte to most
|
||||
// Windows-authored filenames and never loses characters.
|
||||
var normalized = path.Normalize(NormalizationForm.FormC);
|
||||
|
||||
// Trim only trailing whitespace. Windows ignores trailing spaces in
|
||||
// file/dir names, and copy/paste often leaves a stray space (or NBSP)
|
||||
// behind. We do NOT trim trailing dots — that would be lossy.
|
||||
normalized = TrimTrailingWhitespace(normalized);
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Strip long-path / NT object prefixes. .NET's filesystem APIs accept
|
||||
// both prefixed and un-prefixed forms; strings stored in a bookmark
|
||||
// typically lack the prefix so we normalize toward the bare form.
|
||||
if (normalized.StartsWith(LongUncPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return @"\\" + normalized[LongUncPrefix.Length..];
|
||||
}
|
||||
|
||||
if (normalized.StartsWith(LongPathPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalized[LongPathPrefix.Length..];
|
||||
}
|
||||
|
||||
if (normalized.StartsWith(NtObjectPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalized[NtObjectPrefix.Length..];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string TrimTrailingWhitespace(string value)
|
||||
{
|
||||
var end = value.Length;
|
||||
while (end > 0 && char.IsWhiteSpace(value[end - 1]))
|
||||
{
|
||||
end--;
|
||||
}
|
||||
|
||||
return end == value.Length ? value : value[..end];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user