mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-16 03:37:59 +01:00
CmdPal: A couple more run commands bugs (#42174)
Caching bugs are hard. This fixes like, three different run commands bugs: * typing `c:\windows\p`, then backspacing to `c:\windows` would populate the cache for `c:\` with the files in `c:\` that matched `windows*`. * Now when the dir chenges, we correctly fill the cache with everything in that dir, then filter it. * that also caused a similar edge case for `c:\windows\` -> `c:\windows` (the first should show results under c:\windows\` the second should only show things in `c:\` matching `windows` * As of my last PR, we support commandlines with spaces. We however forgot to handle _paths_ with spaces. We'll now correctly show path results for something like `c:\program files\`
This commit is contained in:
@@ -3,11 +3,13 @@
|
|||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.CmdPal.Core.Common.Services;
|
using Microsoft.CmdPal.Core.Common.Services;
|
||||||
using Microsoft.CmdPal.Ext.Shell.Pages;
|
using Microsoft.CmdPal.Ext.Shell.Pages;
|
||||||
using Microsoft.CmdPal.Ext.UnitTestBase;
|
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
@@ -153,4 +155,131 @@ public class QueryTests : CommandPaletteUnitTestBase
|
|||||||
// Should find at least the ping command from history
|
// Should find at least the ping command from history
|
||||||
Assert.IsTrue(commandList.Length > 1);
|
Assert.IsTrue(commandList.Length > 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task TestCacheBackToSameDirectory()
|
||||||
|
{
|
||||||
|
// Setup
|
||||||
|
var settings = Settings.CreateDefaultSettings();
|
||||||
|
var mockHistoryService = CreateMockHistoryService();
|
||||||
|
|
||||||
|
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||||
|
|
||||||
|
// Load up everything in c:\, for the sake of comparing:
|
||||||
|
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||||
|
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||||
|
|
||||||
|
var commandList = page.GetItems();
|
||||||
|
|
||||||
|
// Should find only items for what's in c:\
|
||||||
|
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||||
|
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Win"; });
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||||
|
|
||||||
|
commandList = page.GetItems();
|
||||||
|
|
||||||
|
// Should still find everything
|
||||||
|
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||||
|
|
||||||
|
await TypeStringIntoPage(page, "c:\\Windows\\Pro");
|
||||||
|
await BackspaceSearchText(page, "c:\\Windows\\Pro", 3); // 3 characters for c:\
|
||||||
|
|
||||||
|
commandList = page.GetItems();
|
||||||
|
|
||||||
|
// Should still find everything
|
||||||
|
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TypeStringIntoPage(IDynamicListPage page, string searchText)
|
||||||
|
{
|
||||||
|
// type the string one character at a time
|
||||||
|
for (var i = 0; i < searchText.Length; i++)
|
||||||
|
{
|
||||||
|
var substr = searchText[..i];
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BackspaceSearchText(IDynamicListPage page, string originalSearchText, int finalStringLength)
|
||||||
|
{
|
||||||
|
var originalLength = originalSearchText.Length;
|
||||||
|
for (var i = originalLength; i >= finalStringLength; i--)
|
||||||
|
{
|
||||||
|
var substr = originalSearchText[..i];
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task TestCacheSameDirectorySlashy()
|
||||||
|
{
|
||||||
|
// Setup
|
||||||
|
var settings = Settings.CreateDefaultSettings();
|
||||||
|
var mockHistoryService = CreateMockHistoryService();
|
||||||
|
|
||||||
|
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||||
|
|
||||||
|
// Load up everything in c:\, for the sake of comparing:
|
||||||
|
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||||
|
var filesInWindows = Directory.EnumerateFileSystemEntries("C:\\Windows");
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||||
|
|
||||||
|
var commandList = page.GetItems();
|
||||||
|
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||||
|
|
||||||
|
// First navigate to c:\Windows. This should match everything that matches "windows" inside of C:\
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||||
|
var cWindowsCommandsPre = page.GetItems();
|
||||||
|
|
||||||
|
// Then go into c:\windows\. This will only have the results in c:\windows\
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows\\"; });
|
||||||
|
var windowsCommands = page.GetItems();
|
||||||
|
Assert.IsTrue(windowsCommands.Length != cWindowsCommandsPre.Length);
|
||||||
|
|
||||||
|
// now go back to c:\windows. This should match the results from the last time we entered this string
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||||
|
var cWindowsCommandsPost = page.GetItems();
|
||||||
|
Assert.IsTrue(cWindowsCommandsPre.Length == cWindowsCommandsPost.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task TestPathWithSpaces()
|
||||||
|
{
|
||||||
|
// Setup
|
||||||
|
var settings = Settings.CreateDefaultSettings();
|
||||||
|
var mockHistoryService = CreateMockHistoryService();
|
||||||
|
|
||||||
|
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||||
|
|
||||||
|
// Load up everything in c:\, for the sake of comparing:
|
||||||
|
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||||
|
var filesInProgramFiles = Directory.EnumerateFileSystemEntries("C:\\Program Files");
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; });
|
||||||
|
|
||||||
|
var commandList = page.GetItems();
|
||||||
|
Assert.IsTrue(commandList.Length == filesInProgramFiles.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task TestNoWrapSuggestionsWithSpaces()
|
||||||
|
{
|
||||||
|
// Setup
|
||||||
|
var settings = Settings.CreateDefaultSettings();
|
||||||
|
var mockHistoryService = CreateMockHistoryService();
|
||||||
|
|
||||||
|
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||||
|
|
||||||
|
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; });
|
||||||
|
|
||||||
|
var commandList = page.GetItems();
|
||||||
|
|
||||||
|
foreach (var item in commandList)
|
||||||
|
{
|
||||||
|
Assert.IsTrue(!string.IsNullOrEmpty(item.TextToSuggest));
|
||||||
|
Assert.IsFalse(item.TextToSuggest.StartsWith('"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.CommandPalette.Extensions;
|
using Microsoft.CommandPalette.Extensions;
|
||||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using Windows.Foundation;
|
||||||
|
|
||||||
namespace Microsoft.CmdPal.Ext.UnitTestBase;
|
namespace Microsoft.CmdPal.Ext.UnitTestBase;
|
||||||
|
|
||||||
@@ -32,9 +33,14 @@ public class CommandPaletteUnitTestBase
|
|||||||
// and wait for the event to be raised.
|
// and wait for the event to be raised.
|
||||||
var tcs = new TaskCompletionSource<object>();
|
var tcs = new TaskCompletionSource<object>();
|
||||||
|
|
||||||
page.ItemsChanged += (sender, args) => tcs.SetResult(null);
|
TypedEventHandler<object, IItemsChangedEventArgs> handleItemsChanged = (object s, IItemsChangedEventArgs e) =>
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
page.ItemsChanged += handleItemsChanged;
|
||||||
modification();
|
modification();
|
||||||
|
|
||||||
await tcs.Task;
|
await tcs.Task;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public static class CommandLineNormalizer
|
|||||||
///
|
///
|
||||||
/// The resulting strings are used for comparisons in profile matching.
|
/// The resulting strings are used for comparisons in profile matching.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static string NormalizeCommandLine(string commandLine)
|
public static string NormalizeCommandLine(string commandLine, bool allowDirectory)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(commandLine))
|
if (string.IsNullOrEmpty(commandLine))
|
||||||
{
|
{
|
||||||
@@ -79,7 +79,7 @@ public static class CommandLineNormalizer
|
|||||||
// The given commandLine should start with an executable name or path.
|
// The given commandLine should start with an executable name or path.
|
||||||
// This loop tries to resolve relative paths, as well as executable names in %PATH%
|
// This loop tries to resolve relative paths, as well as executable names in %PATH%
|
||||||
// into absolute paths and normalizes them.
|
// into absolute paths and normalizes them.
|
||||||
var executablePath = ResolveExecutablePath(argv, ref startOfArguments);
|
var executablePath = ResolveExecutablePath(argv, allowDirectory, ref startOfArguments);
|
||||||
|
|
||||||
// We've (hopefully) finished resolving the path to the executable.
|
// We've (hopefully) finished resolving the path to the executable.
|
||||||
// We're now going to append all remaining arguments to the resulting string.
|
// We're now going to append all remaining arguments to the resulting string.
|
||||||
@@ -163,7 +163,7 @@ public static class CommandLineNormalizer
|
|||||||
/// Resolves the executable path from the command line arguments.
|
/// Resolves the executable path from the command line arguments.
|
||||||
/// Handles cases where the path contains spaces and was split during parsing.
|
/// Handles cases where the path contains spaces and was split during parsing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string ResolveExecutablePath(string[] argv, ref int startOfArguments)
|
private static string ResolveExecutablePath(string[] argv, bool allowDirectory, ref int startOfArguments)
|
||||||
{
|
{
|
||||||
if (argv.Length == 0)
|
if (argv.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -183,7 +183,7 @@ public static class CommandLineNormalizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
var candidatePath = pathBuilder.ToString();
|
var candidatePath = pathBuilder.ToString();
|
||||||
var resolvedPath = TryResolveExecutable(candidatePath);
|
var resolvedPath = TryResolveExecutable(candidatePath, allowDirectory);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(resolvedPath))
|
if (!string.IsNullOrEmpty(resolvedPath))
|
||||||
{
|
{
|
||||||
@@ -200,7 +200,7 @@ public static class CommandLineNormalizer
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to resolve an executable path using SearchPathW.
|
/// Attempts to resolve an executable path using SearchPathW.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string TryResolveExecutable(string executableName)
|
private static string TryResolveExecutable(string executableName, bool allowDirectory)
|
||||||
{
|
{
|
||||||
var buffer = new char[MAX_PATH];
|
var buffer = new char[MAX_PATH];
|
||||||
|
|
||||||
@@ -234,11 +234,15 @@ public static class CommandLineNormalizer
|
|||||||
|
|
||||||
var resolvedPath = new string(buffer, 0, (int)result);
|
var resolvedPath = new string(buffer, 0, (int)result);
|
||||||
|
|
||||||
// Verify the resolved path exists and is not a directory
|
// Verify the resolved path exists...
|
||||||
var attributes = PInvoke.GetFileAttributes(resolvedPath);
|
var attributes = PInvoke.GetFileAttributes(resolvedPath);
|
||||||
|
|
||||||
|
// ... and if we don't want to allow directories, reject paths that are dirs
|
||||||
|
var rejectDirectory = !allowDirectory &&
|
||||||
|
(attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0;
|
||||||
|
|
||||||
return attributes == INVALID_FILE_ATTRIBUTES ||
|
return attributes == INVALID_FILE_ATTRIBUTES ||
|
||||||
(attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0 ?
|
rejectDirectory ?
|
||||||
string.Empty :
|
string.Empty :
|
||||||
resolvedPath;
|
resolvedPath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public class ShellListPageHelpers
|
|||||||
|
|
||||||
internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
|
internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
|
||||||
{
|
{
|
||||||
// TODO! remove this method and just use ShellHelpers.FileExistInPath directly
|
|
||||||
return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None);
|
return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ public class ShellListPageHelpers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void NormalizeCommandLineAndArgs(string input, out string executable, out string arguments)
|
public static void NormalizeCommandLineAndArgs(string input, out string executable, out string arguments)
|
||||||
{
|
{
|
||||||
var normalized = CommandLineNormalizer.NormalizeCommandLine(input);
|
var normalized = CommandLineNormalizer.NormalizeCommandLine(input, allowDirectory: true);
|
||||||
var segments = normalized.Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
var segments = normalized.Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
||||||
executable = string.Empty;
|
executable = string.Empty;
|
||||||
arguments = string.Empty;
|
arguments = string.Empty;
|
||||||
|
|||||||
@@ -50,26 +50,7 @@ internal sealed partial class PathListItem : ListItem
|
|||||||
Title = fileName; // Just the name of the file is the Title
|
Title = fileName; // Just the name of the file is the Title
|
||||||
Subtitle = path; // What the user typed is the subtitle
|
Subtitle = path; // What the user typed is the subtitle
|
||||||
|
|
||||||
// NOTE ME:
|
TextToSuggest = path;
|
||||||
// If there are spaces on originalDir, trim them off, BEFORE combining originalDir and fileName.
|
|
||||||
// THEN add quotes at the end
|
|
||||||
|
|
||||||
// Trim off leading & trailing quote, if there is one
|
|
||||||
var trimmed = originalDir.Trim('"');
|
|
||||||
var originalPath = Path.Combine(trimmed, fileName);
|
|
||||||
var suggestion = originalPath;
|
|
||||||
var hasSpace = originalPath.Contains(' ');
|
|
||||||
if (hasSpace)
|
|
||||||
{
|
|
||||||
// wrap it in quotes
|
|
||||||
suggestion = string.Concat("\"", suggestion, "\"");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
suggestion = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
TextToSuggest = suggestion;
|
|
||||||
|
|
||||||
MoreCommands = [
|
MoreCommands = [
|
||||||
new CommandContextItem(new OpenWithCommand(path)),
|
new CommandContextItem(new OpenWithCommand(path)),
|
||||||
|
|||||||
@@ -434,66 +434,67 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directoryPath == _currentSubdir)
|
// If the directory we're in changed, then first rebuild the cache
|
||||||
|
// of all the items in the directory, _then_ filter them below.
|
||||||
|
if (directoryPath != _currentSubdir)
|
||||||
{
|
{
|
||||||
// Filter the items we already had
|
// Get all the files in the directory.
|
||||||
var fuzzyString = searchPattern.TrimEnd('*');
|
// Run this on a background thread to avoid blocking
|
||||||
var newMatchedPathItems = new List<ListItem>();
|
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath), cancellationToken);
|
||||||
|
|
||||||
foreach (var kv in _currentPathItems)
|
// Check for cancellation after file enumeration
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var score = string.IsNullOrEmpty(fuzzyString) ?
|
return;
|
||||||
1 :
|
|
||||||
FuzzyStringMatcher.ScoreFuzzy(fuzzyString, kv.Key);
|
|
||||||
if (score > 0)
|
|
||||||
{
|
|
||||||
newMatchedPathItems.Add(kv.Value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ListHelpers.InPlaceUpdateList(_pathItems, newMatchedPathItems);
|
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length));
|
||||||
return;
|
var originalBeginning = originalPath.EndsWith(searchPathTrailer, StringComparison.CurrentCultureIgnoreCase) ?
|
||||||
|
originalPath.Remove(originalPath.Length - searchPathTrailer.Length) :
|
||||||
|
originalPath;
|
||||||
|
|
||||||
|
if (isDriveRoot)
|
||||||
|
{
|
||||||
|
originalBeginning = string.Concat(originalBeginning, '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a list of commands for each file
|
||||||
|
var newPathItems = files
|
||||||
|
.Select(f => PathToListItem(f, originalBeginning))
|
||||||
|
.ToDictionary(item => item.Title, item => item);
|
||||||
|
|
||||||
|
// Final cancellation check before updating results
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the commands to the list
|
||||||
|
_pathItems.Clear();
|
||||||
|
_currentSubdir = directoryPath;
|
||||||
|
_currentPathItems.Clear();
|
||||||
|
foreach ((var k, IListItem v) in newPathItems)
|
||||||
|
{
|
||||||
|
_currentPathItems[k] = (ListItem)v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all the files in the directory that start with the search text
|
// Filter the items from this directory
|
||||||
// Run this on a background thread to avoid blocking
|
var fuzzyString = searchPattern.TrimEnd('*');
|
||||||
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken);
|
var newMatchedPathItems = new List<ListItem>();
|
||||||
|
|
||||||
// Check for cancellation after file enumeration
|
foreach (var kv in _currentPathItems)
|
||||||
if (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
{
|
||||||
return;
|
var score = string.IsNullOrEmpty(fuzzyString) ?
|
||||||
|
1 :
|
||||||
|
FuzzyStringMatcher.ScoreFuzzy(fuzzyString, kv.Key);
|
||||||
|
if (score > 0)
|
||||||
|
{
|
||||||
|
newMatchedPathItems.Add(kv.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length));
|
ListHelpers.InPlaceUpdateList(_pathItems, newMatchedPathItems);
|
||||||
var originalBeginning = originalPath.EndsWith(searchPathTrailer, StringComparison.CurrentCultureIgnoreCase) ?
|
|
||||||
originalPath.Remove(originalPath.Length - searchPathTrailer.Length) :
|
|
||||||
originalPath;
|
|
||||||
|
|
||||||
if (isDriveRoot)
|
|
||||||
{
|
|
||||||
originalBeginning = string.Concat(originalBeginning, '\\');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a list of commands for each file
|
|
||||||
var newPathItems = files
|
|
||||||
.Select(f => PathToListItem(f, originalBeginning))
|
|
||||||
.ToDictionary(item => item.Title, item => item);
|
|
||||||
|
|
||||||
// Final cancellation check before updating results
|
|
||||||
if (cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the commands to the list
|
|
||||||
_pathItems = newPathItems.Values.ToList();
|
|
||||||
_currentSubdir = directoryPath;
|
|
||||||
_currentPathItems.Clear();
|
|
||||||
foreach ((var k, IListItem v) in newPathItems)
|
|
||||||
{
|
|
||||||
_currentPathItems[k] = (ListItem)v;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user