diff --git a/src/Files.Controls/Omnibar/Omnibar.cs b/src/Files.Controls/Omnibar/Omnibar.cs index 709ec8b..6c88d2e 100644 --- a/src/Files.Controls/Omnibar/Omnibar.cs +++ b/src/Files.Controls/Omnibar/Omnibar.cs @@ -260,6 +260,17 @@ internal protected void ChangeTextBoxText(string text) } } + internal void ChangeTextBoxTextFromMode(string text) + { + if (string.Equals(_textBox.Text, text, StringComparison.Ordinal)) + { + return; + } + + _textChangeReason = OmnibarTextChangeReason.ProgrammaticChange; + ChangeTextBoxText(text); + } + private void SubmitQuery(object? item) { if (CurrentSelectedMode is null) diff --git a/src/Files.Controls/Omnibar/OmnibarMode.Properties.cs b/src/Files.Controls/Omnibar/OmnibarMode.Properties.cs index efbef33..7d4d2a2 100644 --- a/src/Files.Controls/Omnibar/OmnibarMode.Properties.cs +++ b/src/Files.Controls/Omnibar/OmnibarMode.Properties.cs @@ -46,10 +46,11 @@ public partial class OmnibarMode partial void OnTextChanged(string? newValue) { if (_ownerRef is null || _ownerRef.TryGetTarget(out var owner) is false) - + { return; + } - owner.ChangeTextBoxText(newValue ?? string.Empty); + owner.ChangeTextBoxTextFromMode(newValue ?? string.Empty); } } } diff --git a/src/Files.Core/Browsing/BrowseSession.cs b/src/Files.Core/Browsing/BrowseSession.cs index d721211..000de14 100644 --- a/src/Files.Core/Browsing/BrowseSession.cs +++ b/src/Files.Core/Browsing/BrowseSession.cs @@ -16,6 +16,7 @@ namespace Files.Core.Browsing; public sealed class BrowseSession : IBrowseSession, IBrowsePrefetchTarget, IInteractiveBrowseSession { private const int InitialEnumerationBatchSize = 32; + private const int SearchInitialEnumerationBatchSize = 1; private const int EnumerationBatchSize = 256; private const int MaximumEnumerationBatchSize = 1024; private static readonly TimeSpan PropertySortDebounce = TimeSpan.FromMilliseconds(150); @@ -178,8 +179,8 @@ private async ValueTask NavigateCoreAsync(BrowseLocation location, nint ownerWin await nextContext.StartAsync(cancellationToken).ConfigureAwait(false); nextProjection = new BrowseItemProjection(nextViewSettings, _presentationStore.GetSortPropertyValue); - var pendingBatch = new List(InitialEnumerationBatchSize); - var targetBatchSize = InitialEnumerationBatchSize; + var targetBatchSize = location is SearchLocation ? SearchInitialEnumerationBatchSize : InitialEnumerationBatchSize; + var pendingBatch = new List(targetBatchSize); var firstItemReturned = false; CoreDiagnosticLog.Write("BrowseSession", $"Enumeration START generation={generation} elapsedMs={Stopwatch.GetElapsedTime(navigationStartTimestamp).TotalMilliseconds:F1}"); var nextItemSequence = ownerWindowHandle is not 0 && nextLocationContext is IInteractiveBrowseLocationContext interactiveContext @@ -214,7 +215,12 @@ private async ValueTask NavigateCoreAsync(BrowseLocation location, nint ownerWin : await SortInitialEnumerationBatchAsync(nextLocationContext, nextProjection, pendingBatch, nextViewSettings, cancellationToken).ConfigureAwait(false); PublishEnumerationBatch(location, nextViewSettings, nextContext, nextProjection, batchToPublish, ref previousState, ref enumerationActivated); pendingBatch.Clear(); - targetBatchSize = Math.Min(MaximumEnumerationBatchSize, enumerationActivated && targetBatchSize is InitialEnumerationBatchSize ? EnumerationBatchSize : checked(targetBatchSize * 2)); + targetBatchSize = targetBatchSize switch + { + SearchInitialEnumerationBatchSize => InitialEnumerationBatchSize, + InitialEnumerationBatchSize => EnumerationBatchSize, + _ => Math.Min(MaximumEnumerationBatchSize, checked(targetBatchSize * 2)), + }; await Task.Yield(); } CoreDiagnosticLog.Write("BrowseSession", $"Enumeration END generation={generation} items={nextItems.Count} elapsedMs={Stopwatch.GetElapsedTime(navigationStartTimestamp).TotalMilliseconds:F1}"); diff --git a/src/Files.Core/Browsing/FolderBrowseLocationContext.cs b/src/Files.Core/Browsing/FolderBrowseLocationContext.cs index fa107f9..896765c 100644 --- a/src/Files.Core/Browsing/FolderBrowseLocationContext.cs +++ b/src/Files.Core/Browsing/FolderBrowseLocationContext.cs @@ -14,7 +14,7 @@ namespace Files.Core.Browsing; /// /// Keeps a resolved folder model alive for the duration of a browse location. /// -public sealed class FolderBrowseLocationContext : IBrowseLocationContext, IBrowseLocationItemResolver, IBrowseLocationItemSorter, IInteractiveBrowseLocationContext +public sealed class FolderBrowseLocationContext : IBrowseLocationContext, IBrowseLocationItemResolver, IBrowseLocationItemSorter, IInteractiveBrowseLocationContext, IWindowsShellColumnProvider { private readonly FolderLocation _location; diff --git a/src/Files.Core/NativeMethods.txt b/src/Files.Core/NativeMethods.txt index 733c9c9..e7e7e39 100644 --- a/src/Files.Core/NativeMethods.txt +++ b/src/Files.Core/NativeMethods.txt @@ -518,6 +518,19 @@ SafeArrayGetDim SafeArrayGetLBound SafeArrayGetUBound IObjectWithSite +IQueryContinue +ICondition +IConditionFactory +IConditionFactory2 +ISchemaProvider +IEntity +ITokenCollection +QUERY_PARSER_MANAGER_OPTION +STRUCTURED_QUERY_MULTIOPTION +STRUCTURED_QUERY_SINGLE_OPTION +ISearchFolderItemFactory +QueryParserManager +SearchFolderItemFactory IOpenControlPanel OpenControlPanel SharingConfigurationManager diff --git a/src/Files.Core/Sessions/BrowsePaneSession.cs b/src/Files.Core/Sessions/BrowsePaneSession.cs index 9505f6a..cce3a46 100644 --- a/src/Files.Core/Sessions/BrowsePaneSession.cs +++ b/src/Files.Core/Sessions/BrowsePaneSession.cs @@ -14,6 +14,12 @@ public enum PaneNavigationMode /// Replaces the current history entry. Replace, + + /// Adds the search target unless the current committed entry is another search, which it replaces. + UpdateSearch, + + /// Moves back to a matching search origin when possible; otherwise replaces the current entry. + ExitSearch, } /// @@ -89,7 +95,7 @@ public async ValueTask NavigateAsync(BrowseLocation location, PaneNavigationMode { ArgumentNullException.ThrowIfNull(location); - if (mode is not PaneNavigationMode.Push and not PaneNavigationMode.Replace) + if (mode is not PaneNavigationMode.Push and not PaneNavigationMode.Replace and not PaneNavigationMode.UpdateSearch and not PaneNavigationMode.ExitSearch) { throw new ArgumentOutOfRangeException(nameof(mode)); } @@ -100,10 +106,7 @@ public async ValueTask NavigateAsync(BrowseLocation location, PaneNavigationMode try { EnsureActive(); - await NavigateAndCommitAsync(location, () => - { - if (mode is PaneNavigationMode.Push) { History.Push(location); } else { History.Replace(location); } - }, ownerWindowHandle, navigation.Token).ConfigureAwait(false); + await NavigateAndCommitAsync(location, () => CommitNavigationHistory(location, mode), ownerWindowHandle, navigation.Token).ConfigureAwait(false); } finally { @@ -294,6 +297,46 @@ public ValueTask DisposeAsync() } } + private void CommitNavigationHistory(BrowseLocation location, PaneNavigationMode mode) + { + if (mode is PaneNavigationMode.Push) + { + History.Push(location); + + return; + } + + if (mode is PaneNavigationMode.Replace) + { + History.Replace(location); + + return; + } + + if (mode is PaneNavigationMode.UpdateSearch) + { + if (History.Current is SearchLocation) + { + History.Replace(location); + } + else + { + History.Push(location); + } + + return; + } + + if (History.Current is SearchLocation && History.TryGetBack(out var previous, out var targetIndex) && Equals(previous, location)) + { + History.TryMoveTo(targetIndex, location); + + return; + } + + History.Replace(location); + } + private async Task NavigateAndCommitAsync(BrowseLocation target, Action commitHistory, nint ownerWindowHandle, CancellationToken cancellationToken) { var previousGeneration = BrowseSession.Generation; diff --git a/src/Files.Core/Windows/Interop/Extras.cs b/src/Files.Core/Windows/Interop/Extras.cs index 39705d7..b6de209 100644 --- a/src/Files.Core/Windows/Interop/Extras.cs +++ b/src/Files.Core/Windows/Interop/Extras.cs @@ -5,12 +5,14 @@ using System; using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; using Microsoft.Win32.SafeHandles; using Windows.Win32.Foundation; using Windows.Win32.System.Com; using Windows.Win32.System.Com.Urlmon; using Windows.Win32.System.Com.StructuredStorage; using Windows.Win32.System.IO; +using Windows.Win32.System.Search.Common; using Windows.Win32.UI.Shell; using Windows.Win32.UI.Shell.Common; using Windows.Win32.UI.Shell.PropertiesSystem; @@ -99,3 +101,153 @@ internal static partial NTSTATUS NtFsControlFile(SafeFileHandle fileHandle, Safe nint inputBuffer, uint inputBufferLength, ref byte outputBuffer, uint outputBufferLength); } } + +namespace Windows.Win32.System.Com +{ + /// Provides a strongly typed query-continuation service to Windows Shell enumerators. + [GeneratedComInterface(Options = ComInterfaceOptions.ManagedObjectWrapper)] + [Guid("6D5140C1-7436-11CE-8034-00AA006009FA")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal partial interface IQueryContinueServiceProvider + { + /// Returns the supported query-continuation service. + /// The requested service identifier. + /// The requested interface identifier. + /// Receives the query-continuation service. + /// The HRESULT describing whether the service is available. + [PreserveSig] + HRESULT QueryService(in Guid serviceId, in Guid interfaceId, out IQueryContinue? service); + } +} + +namespace Windows.Win32.System.Search +{ + /// Creates configured Windows Structured Query parsers. + [GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16, Options = ComInterfaceOptions.ComObjectWrapper)] + [Guid("A879E3C4-AF77-44FB-8F37-EBD1487CF920")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal partial interface IQueryParserManager + { + /// Creates and loads a parser for a catalog and keyword language. + /// The catalog name. + /// The keyword language identifier. + /// The requested parser interface identifier. + /// Receives the parser. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT CreateLoadedParser(string catalog, ushort keywordLanguage, in Guid interfaceId, out IQueryParser? queryParser); + + /// Initializes natural-query and wildcard options on a parser. + /// Whether natural query syntax is enabled. + /// Whether automatic wildcard matching is enabled. + /// The parser to initialize. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT InitializeOptions(BOOL understandNaturalQuerySyntax, BOOL automaticWildcard, IQueryParser? queryParser); + + /// Sets a parser-manager option. + /// The option to set. + /// The option value. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT SetOption(QUERY_PARSER_MANAGER_OPTION option, in PROPVARIANT value); + } + + /// Parses Windows Structured Query input. + [GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16, Options = ComInterfaceOptions.ComObjectWrapper)] + [Guid("2EBDEE67-3505-43F8-9946-EA44ABC8E5B0")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal partial interface IQueryParser + { + /// Parses query text into a query solution. + /// The query text. + /// The optional custom-property enumerator. + /// Receives the query solution. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT Parse(string input, IEnumUnknown? customProperties, out IQuerySolution? solution); + + /// Sets a single parser option. + /// The option to set. + /// The option value. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT SetOption(STRUCTURED_QUERY_SINGLE_OPTION option, in PROPVARIANT value); + + /// Gets a single parser option. + /// The option to get. + /// Receives the option value. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT GetOption(STRUCTURED_QUERY_SINGLE_OPTION option, out PROPVARIANT value); + + /// Sets a keyed parser option. + /// The multi-option to set. + /// The option key. + /// The option value. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT SetMultiOption(STRUCTURED_QUERY_MULTIOPTION option, string optionKey, in PROPVARIANT value); + + /// Gets the parser schema provider. + /// Receives the schema provider. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT GetSchemaProvider(out ISchemaProvider? schemaProvider); + + /// Restates a condition as query text. + /// The optional condition. + /// Whether to use English keywords. + /// Receives the allocated query text. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT RestateToString(ICondition? condition, BOOL useEnglish, out PWSTR queryString); + + /// Parses a value for a named property. + /// The canonical property name. + /// The property-value text. + /// Receives the query solution. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT ParsePropertyValue(string propertyName, string input, out IQuerySolution? solution); + + /// Restates a property condition as property and query text. + /// The optional condition. + /// Whether to use English keywords. + /// Receives the allocated property name. + /// Receives the allocated query text. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT RestatePropertyValueToString(ICondition? condition, BOOL useEnglish, out PWSTR propertyName, out PWSTR queryString); + } + + /// Contains a parsed Structured Query condition and diagnostics. + [GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16, Options = ComInterfaceOptions.ComObjectWrapper)] + [Guid("D6EBC66B-8921-4193-AFDD-A1789FB7FF57")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal unsafe partial interface IQuerySolution : IConditionFactory + { + /// Gets the parsed query condition and optional main entity type. + /// Receives the query condition. + /// Receives the optional main entity type. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT GetQuery(out ICondition? queryNode, out IEntity? mainType); + + /// Gets parse errors through a requested enumerator interface. + /// The requested parse-error interface identifier. + /// Receives the parse-error interface. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT GetErrors(in Guid interfaceId, [MarshalAs(UnmanagedType.Interface)] out object? parseErrors); + + /// Gets the lexical data retained by the parser. + /// Receives the allocated input text. + /// Receives the token collection. + /// Receives the input locale identifier. + /// Receives the word breaker. + /// The HRESULT returned by Structured Query. + [PreserveSig] + HRESULT GetLexicalData(out PWSTR inputString, out ITokenCollection? tokens, out uint locale, [MarshalAs(UnmanagedType.Interface)] out object? wordBreaker); + } +} diff --git a/src/Files.Core/Windows/Items/WindowsShellItemResolver.cs b/src/Files.Core/Windows/Items/WindowsShellItemResolver.cs index c356359..5f0792c 100644 --- a/src/Files.Core/Windows/Items/WindowsShellItemResolver.cs +++ b/src/Files.Core/Windows/Items/WindowsShellItemResolver.cs @@ -100,6 +100,15 @@ public Task InvokeConcurrentAsync(WindowsItemLocator locator, Func InvokeCore(locator, action), cancellationToken); } + internal Task InvokeSearchAsync(WindowsItemLocator locator, Func action, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(locator); + + ArgumentNullException.ThrowIfNull(action); + + return _scheduler.InvokeSearchAsync(() => InvokeCore(locator, action), cancellationToken); + } + public Task InvokeOperationAsync(WindowsItemLocator locator, Func action, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(locator); diff --git a/src/Files.Core/Windows/Items/WindowsStorableDescriptor.cs b/src/Files.Core/Windows/Items/WindowsStorableDescriptor.cs index 423c89d..be21eb6 100644 --- a/src/Files.Core/Windows/Items/WindowsStorableDescriptor.cs +++ b/src/Files.Core/Windows/Items/WindowsStorableDescriptor.cs @@ -11,4 +11,7 @@ namespace Files.Core.Windows; /// /// Describes a Shell item without retaining an apartment-bound COM object. /// -internal sealed record WindowsStorableDescriptor(string ItemId, StorageAddress Address, WindowsItemLocator Locator, WindowsStorableSnapshot Snapshot); +internal sealed record WindowsStorableDescriptor(string ItemId, StorageAddress Address, WindowsItemLocator Locator, WindowsStorableSnapshot Snapshot) +{ + internal bool IsSearchFolder { get; init; } +} diff --git a/src/Files.Core/Windows/Items/WindowsStorableFactory.cs b/src/Files.Core/Windows/Items/WindowsStorableFactory.cs index c8e8a3e..b868650 100644 --- a/src/Files.Core/Windows/Items/WindowsStorableFactory.cs +++ b/src/Files.Core/Windows/Items/WindowsStorableFactory.cs @@ -32,6 +32,8 @@ internal sealed class WindowsStorableFactory private const int IdentityWorkerCount = 4; + private const int SearchEnumerationBatchSize = 1; + private readonly IWindowsShellScheduler _scheduler; private readonly IWindowsItemIdReader _itemIdReader; @@ -83,6 +85,27 @@ internal Task CreateDesktopAsync(CancellationToken cancellation cancellationToken); } + internal Task CreateSearchFolderAsync(string query, IReadOnlyList? scopeLocators, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query); + + if (scopeLocators is { Count: 0 }) + { + throw new ArgumentException("A supplied Windows Shell search scope cannot be empty.", nameof(scopeLocators)); + } + + return _scheduler.InvokeAsync( + () => + { + var shellItem = WindowsShellSearchFolderFactory.Create(query, scopeLocators); + var descriptor = ShellItemHelpers.CreateDescriptor(shellItem, _itemIdReader) with { IsSearchFolder = true }; + var storable = Create(descriptor); + + return storable as WindowsFolder ?? throw new InvalidOperationException("The Windows Shell search factory did not return a folder."); + }, + cancellationToken); + } + public Task TryCreateAsync(string parsingName, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(parsingName)) @@ -168,10 +191,11 @@ internal async IAsyncEnumerable EnumerateChildrenAsyn Task? producer = null; try { - var scheduledProducer = _resolver.InvokeConcurrentAsync( - descriptor.Locator, - shellItem => EnumerateChildrenOnCurrentSta(shellItem, parentFolder, ownerWindow, batches.Writer, enumerationCancellation.Token), - enumerationCancellation.Token); + Func enumerateChildren = shellItem => + EnumerateChildrenOnCurrentSta(shellItem, parentFolder, descriptor.IsSearchFolder, ownerWindow, batches.Writer, enumerationCancellation.Token); + var scheduledProducer = descriptor.IsSearchFolder + ? _resolver.InvokeSearchAsync(descriptor.Locator, enumerateChildren, enumerationCancellation.Token) + : _resolver.InvokeConcurrentAsync(descriptor.Locator, enumerateChildren, enumerationCancellation.Token); producer = CompleteChannelWhenFinishedAsync(scheduledProducer, batches.Writer); await foreach (var batch in batches.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) @@ -354,6 +378,7 @@ private static ReadOnlyMemory CombinePidls(ReadOnlyMemory parentPidl private static unsafe bool EnumerateChildrenOnCurrentSta( IShellItem shellItem, WindowsItemLocator parentFolder, + bool isSearchFolder, HWND ownerWindow, ChannelWriter> writer, CancellationToken cancellationToken) @@ -384,13 +409,15 @@ private static unsafe bool EnumerateChildrenOnCurrentSta( return true; } - ThrowIfEnumerationFailed(hr, ownerWindow); + ThrowIfEnumerationFailed(hr); if (enumerator is null) { throw new InvalidOperationException("The Shell folder returned no item enumerator."); } - var batch = new List(EnumerationBatchSize); + using var cancellationSite = isSearchFolder ? WindowsShellEnumerationCancellationSite.TryAttach(enumerator, cancellationToken) : null; + var enumerationBatchSize = isSearchFolder ? SearchEnumerationBatchSize : EnumerationBatchSize; + var batch = new List(enumerationBatchSize); var childPidls = stackalloc ITEMIDLIST*[EnumerationBatchSize]; var itemStore = WindowsShellItemStore.TryCreate(parentFolder.AbsolutePidl); @@ -406,7 +433,7 @@ private static unsafe bool EnumerateChildrenOnCurrentSta( } var nextStartTimestamp = Stopwatch.GetTimestamp(); - hr = enumerator.Next(EnumerationBatchSize, childPidls, out var fetched); + hr = enumerator.Next((uint)enumerationBatchSize, childPidls, out var fetched); nextCallCount++; nextDuration += Stopwatch.GetElapsedTime(nextStartTimestamp); @@ -415,7 +442,7 @@ private static unsafe bool EnumerateChildrenOnCurrentSta( break; } - ThrowIfEnumerationFailed(hr, ownerWindow); + ThrowIfEnumerationFailed(hr); if (fetched is 0) { break; @@ -451,7 +478,7 @@ private static unsafe bool EnumerateChildrenOnCurrentSta( descriptorDuration += Stopwatch.GetElapsedTime(descriptorStartTimestamp); itemCount++; - if (batch.Count >= EnumerationBatchSize) + if (batch.Count >= enumerationBatchSize) { batchCount++; var channelWriteStartTimestamp = Stopwatch.GetTimestamp(); @@ -461,7 +488,7 @@ private static unsafe bool EnumerateChildrenOnCurrentSta( } channelWriteDuration += Stopwatch.GetElapsedTime(channelWriteStartTimestamp); - batch = new List(EnumerationBatchSize); + batch = new List(enumerationBatchSize); } PInvoke.CoTaskMemFree(childPidl); @@ -544,9 +571,9 @@ private static async Task CompleteChannelWhenFinishedAsync(Task producer, } } - private static void ThrowIfEnumerationFailed(HRESULT hr, HWND ownerWindow) + private static void ThrowIfEnumerationFailed(HRESULT hr) { - if (!ownerWindow.IsNull && hr.Value is CanceledHResultValue) + if (hr.Value is CanceledHResultValue) { throw new OperationCanceledException("The Windows Shell canceled folder enumeration."); } diff --git a/src/Files.Core/Windows/Metadata/IWindowsShellColumnProvider.cs b/src/Files.Core/Windows/Metadata/IWindowsShellColumnProvider.cs new file mode 100644 index 0000000..0c2a429 --- /dev/null +++ b/src/Files.Core/Windows/Metadata/IWindowsShellColumnProvider.cs @@ -0,0 +1,15 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +#pragma warning disable IDE0130 // Windows APIs share a namespace across responsibility folders. + +namespace Files.Core.Windows; + +/// Provides column metadata for a Windows Shell browse location. +public interface IWindowsShellColumnProvider +{ + /// Gets the columns exposed by the current Windows Shell location. + /// The token used to cancel the Shell operation. + /// The Shell column metadata, or when it is unavailable. + ValueTask GetColumnsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Files.Core/Windows/Scheduling/IWindowsShellScheduler.cs b/src/Files.Core/Windows/Scheduling/IWindowsShellScheduler.cs index 9e6b887..2028a43 100644 --- a/src/Files.Core/Windows/Scheduling/IWindowsShellScheduler.cs +++ b/src/Files.Core/Windows/Scheduling/IWindowsShellScheduler.cs @@ -24,6 +24,14 @@ public interface IWindowsShellScheduler : IAsyncDisposable /// Task InvokeConcurrentAsync(Func action, CancellationToken cancellationToken = default); + /// Runs blocking Shell search enumeration on its isolated STA lane. + /// The delegate result type. + /// The synchronous delegate. + /// The token used to cancel queuing. + /// A task containing the delegate result. + /// Implementations without a dedicated search lane fall back to the concurrent lane. + Task InvokeSearchAsync(Func action, CancellationToken cancellationToken = default) => InvokeConcurrentAsync(action, cancellationToken); + /// /// Runs long Shell operations on a separate ordered STA lane. /// diff --git a/src/Files.Core/Windows/Scheduling/WindowsShellScheduler.cs b/src/Files.Core/Windows/Scheduling/WindowsShellScheduler.cs index 8349967..cf37295 100644 --- a/src/Files.Core/Windows/Scheduling/WindowsShellScheduler.cs +++ b/src/Files.Core/Windows/Scheduling/WindowsShellScheduler.cs @@ -33,6 +33,7 @@ public sealed class WindowsShellScheduler : IWindowsShellScheduler private readonly Lock _syncRoot = new(); private readonly MessagePumpedStaScheduler _orderedScheduler; private readonly MessagePumpedStaScheduler _concurrentScheduler; + private readonly MessagePumpedStaScheduler _searchScheduler; private readonly MessagePumpedStaScheduler _operationScheduler; private Task? _disposeTask; @@ -46,6 +47,7 @@ public WindowsShellScheduler(int? concurrentWorkerCount = null) _orderedScheduler = new MessagePumpedStaScheduler("Files Windows Shell STA", workerCount: 1); _concurrentScheduler = new MessagePumpedStaScheduler("Files Windows Shell concurrent STA", workerCount); + _searchScheduler = new MessagePumpedStaScheduler("Files Windows Shell search STA", workerCount: 1); _operationScheduler = new MessagePumpedStaScheduler("Files Windows Shell operation STA", workerCount: 1); CoreDiagnosticLog.Write("WindowsShellScheduler", $"created concurrentWorkers={workerCount}"); } @@ -70,6 +72,16 @@ public Task InvokeConcurrentAsync(Func action, CancellationToken cancel return _concurrentScheduler.InvokeAsync(action, cancellationToken); } + /// Invokes a delegate on the isolated Shell search lane. + /// The delegate result type. + /// The synchronous delegate. + /// The token used to cancel queuing. + /// A task containing the delegate result. + public Task InvokeSearchAsync(Func action, CancellationToken cancellationToken = default) + { + return _searchScheduler.InvokeAsync(action, cancellationToken); + } + /// Invokes a delegate on the ordered operation lane. /// The delegate result type. /// The synchronous delegate. @@ -94,7 +106,11 @@ public ValueTask DisposeAsync() private async Task DisposeCoreAsync() { - await Task.WhenAll(_orderedScheduler.DisposeAsync().AsTask(), _concurrentScheduler.DisposeAsync().AsTask(), _operationScheduler.DisposeAsync().AsTask()).ConfigureAwait(false); + await Task.WhenAll( + _orderedScheduler.DisposeAsync().AsTask(), + _concurrentScheduler.DisposeAsync().AsTask(), + _searchScheduler.DisposeAsync().AsTask(), + _operationScheduler.DisposeAsync().AsTask()).ConfigureAwait(false); GC.SuppressFinalize(this); } diff --git a/src/Files.Core/Windows/Search/WindowsSearchBrowseLocationContext.cs b/src/Files.Core/Windows/Search/WindowsSearchBrowseLocationContext.cs new file mode 100644 index 0000000..b8cb8e1 --- /dev/null +++ b/src/Files.Core/Windows/Search/WindowsSearchBrowseLocationContext.cs @@ -0,0 +1,152 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +#pragma warning disable IDE0130 // Windows APIs share a namespace across responsibility folders. + +using System.Runtime.CompilerServices; +using Files.Core.Browsing; +using Files.Core.Data; +using Files.Core.Models; +using Files.Core.Storage; +using Files.Core.ViewSettings; +using OwlCore.Storage; + +namespace Files.Core.Windows; + +internal sealed class WindowsSearchBrowseLocationContext : + IBrowseLocationContext, + IBrowseLocationItemResolver, + IBrowseLocationItemSorter, + IBrowseLocationParentResolver, + IInteractiveBrowseLocationContext, + IWindowsShellColumnProvider +{ + private readonly IFolderModel _folderModel; + + private readonly SearchLocation _location; + + private readonly IStorageWorkspace _workspace; + + private int _isDisposed; + + /// + public bool CanGetParent => true; + + /// + public BrowseLocation Location => _location; + + /// + public IStorableModel? LocationModel => null; + + internal WindowsSearchBrowseLocationContext(SearchLocation location, IFolderModel folderModel, IStorageWorkspace workspace) + { + ArgumentNullException.ThrowIfNull(location); + + ArgumentNullException.ThrowIfNull(folderModel); + + ArgumentNullException.ThrowIfNull(workspace); + + _location = location; + _folderModel = folderModel; + _workspace = workspace; + } + + /// + public async ValueTask GetColumnsAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this); + + if (_folderModel.GetCoreModel() is not WindowsFolder folder) + { + return null; + } + + return await folder.GetColumnsAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable GetItemsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this); + + await foreach (var item in _folderModel.GetItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + + /// + public ValueTask GetParentLocationAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this); + + cancellationToken.ThrowIfCancellationRequested(); + + return ValueTask.FromResult(_location.Scope is { } scope ? new FolderLocation(scope) : HomeLocation.Instance); + } + + /// + public ValueTask ResolveAsync(StorableReference reference, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this); + + ArgumentNullException.ThrowIfNull(reference); + + return _workspace.ResolveAsync(reference, cancellationToken); + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _isDisposed, 1) == 0) + { + await _folderModel.DisposeAsync().ConfigureAwait(false); + } + + GC.SuppressFinalize(this); + } + + async IAsyncEnumerable IInteractiveBrowseLocationContext.GetItemsAsync(nint ownerWindowHandle, [EnumeratorCancellation] CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this); + + var items = ownerWindowHandle is not 0 && _folderModel is FolderModel folderModel + ? folderModel.GetItemsAsync(StorableType.All, ownerWindowHandle, cancellationToken) + : _folderModel.GetItemsAsync(cancellationToken: cancellationToken); + await foreach (var item in items.ConfigureAwait(false)) + { + yield return item; + } + } + + async ValueTask?> IBrowseLocationItemSorter.SortItemsAsync(IReadOnlyList items, BrowseViewSettings settings, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _isDisposed) != 0, this); + + if (_folderModel.GetCoreModel() is not WindowsFolder folder) + { + return null; + } + + var windowsItems = new WindowsStorable[items.Count]; + var modelsByCoreModel = new Dictionary(ReferenceEqualityComparer.Instance); + for (var index = 0; index < items.Count; index++) + { + if (items[index].GetCoreModel() is not WindowsStorable windowsItem) + { + return null; + } + + windowsItems[index] = windowsItem; + modelsByCoreModel.Add(windowsItem, items[index]); + } + + var sortedItems = await folder.SortChildrenAsync(windowsItems, settings.SortPropertyId, settings.SortDirection, cancellationToken).ConfigureAwait(false); + if (sortedItems is null) + { + return null; + } + + return Array.AsReadOnly(sortedItems.Select(item => modelsByCoreModel[item]).ToArray()); + } +} diff --git a/src/Files.Core/Windows/Search/WindowsSearchBrowseLocationHandler.cs b/src/Files.Core/Windows/Search/WindowsSearchBrowseLocationHandler.cs new file mode 100644 index 0000000..6727f79 --- /dev/null +++ b/src/Files.Core/Windows/Search/WindowsSearchBrowseLocationHandler.cs @@ -0,0 +1,50 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +#pragma warning disable IDE0130 // Windows APIs share a namespace across responsibility folders. + +using Files.Core.Browsing; +using Files.Core.Data; +using Files.Core.Models; + +namespace Files.Core.Windows; + +internal sealed class WindowsSearchBrowseLocationHandler : IBrowseLocationHandler +{ + private readonly WindowsStorageSource _source; + + private readonly StorageWorkspace _workspace; + + internal WindowsSearchBrowseLocationHandler(StorageWorkspace workspace, WindowsStorageSource source) + { + ArgumentNullException.ThrowIfNull(workspace); + + ArgumentNullException.ThrowIfNull(source); + + _workspace = workspace; + _source = source; + } + + /// + public bool CanHandle(BrowseLocation location) => location is SearchLocation search && (search.Scope is null || search.Scope.SourceId == _source.SourceId); + + /// + public async ValueTask OpenAsync(BrowseLocation location, CancellationToken cancellationToken = default) + { + if (location is not SearchLocation searchLocation) + { + throw new ArgumentException("The location must identify a search.", nameof(location)); + } + + var searchFolder = await _source.CreateSearchFolderAsync(searchLocation.Query, searchLocation.Scope, cancellationToken).ConfigureAwait(false); + var model = _workspace.ModelFactory.Create(_source, searchFolder); + if (model is not IFolderModel folderModel) + { + await model.DisposeAsync().ConfigureAwait(false); + + throw new InvalidOperationException("The Windows Shell search did not create a folder model."); + } + + return new WindowsSearchBrowseLocationContext(searchLocation, folderModel, _workspace); + } +} diff --git a/src/Files.Core/Windows/Search/WindowsShellEnumerationCancellationSite.cs b/src/Files.Core/Windows/Search/WindowsShellEnumerationCancellationSite.cs new file mode 100644 index 0000000..b23a5a4 --- /dev/null +++ b/src/Files.Core/Windows/Search/WindowsShellEnumerationCancellationSite.cs @@ -0,0 +1,49 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +#pragma warning disable IDE0130 // Windows APIs share a namespace across responsibility folders. + +using Windows.Win32.System.Ole; +using Windows.Win32.UI.Shell; + +namespace Files.Core.Windows; + +internal sealed class WindowsShellEnumerationCancellationSite : IDisposable +{ + private readonly WindowsShellQueryContinue _queryContinue; + + private IObjectWithSite? _target; + + private WindowsShellEnumerationCancellationSite(IObjectWithSite target, WindowsShellQueryContinue queryContinue) + { + _target = target; + _queryContinue = queryContinue; + } + + /// + public void Dispose() + { + var target = Interlocked.Exchange(ref _target, null); + if (target is null) + { + return; + } + + target.SetSite(null!).ThrowOnFailure(); + GC.KeepAlive(_queryContinue); + } + + internal static WindowsShellEnumerationCancellationSite? TryAttach(IEnumIDList enumerator, CancellationToken cancellationToken) + { + var target = enumerator as IObjectWithSite; + if (target is null) + { + return null; + } + + var queryContinue = new WindowsShellQueryContinue(cancellationToken); + target.SetSite(queryContinue).ThrowOnFailure(); + + return new WindowsShellEnumerationCancellationSite(target, queryContinue); + } +} diff --git a/src/Files.Core/Windows/Search/WindowsShellQueryContinue.cs b/src/Files.Core/Windows/Search/WindowsShellQueryContinue.cs new file mode 100644 index 0000000..4f59773 --- /dev/null +++ b/src/Files.Core/Windows/Search/WindowsShellQueryContinue.cs @@ -0,0 +1,34 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +#pragma warning disable IDE0130 // Windows APIs share a namespace across responsibility folders. + +using System.Runtime.InteropServices.Marshalling; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; + +namespace Files.Core.Windows; + +[GeneratedComClass] +internal sealed partial class WindowsShellQueryContinue(CancellationToken cancellationToken) : IQueryContinue, IQueryContinueServiceProvider +{ + /// + public HRESULT QueryContinue() => cancellationToken.IsCancellationRequested ? HRESULT.S_FALSE : HRESULT.S_OK; + + /// + public HRESULT QueryService(in Guid serviceId, in Guid interfaceId, out IQueryContinue? service) + { + var queryContinueId = typeof(IQueryContinue).GUID; + if (serviceId == queryContinueId && interfaceId == queryContinueId) + { + service = this; + + return HRESULT.S_OK; + } + + service = null; + + return HRESULT.E_NOINTERFACE; + } +} diff --git a/src/Files.Core/Windows/Search/WindowsShellSearchFolderFactory.cs b/src/Files.Core/Windows/Search/WindowsShellSearchFolderFactory.cs new file mode 100644 index 0000000..3ad600f --- /dev/null +++ b/src/Files.Core/Windows/Search/WindowsShellSearchFolderFactory.cs @@ -0,0 +1,82 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +#pragma warning disable IDE0130 // Windows APIs share a namespace across responsibility folders. + +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.System.Search; +using Windows.Win32.UI.Shell; + +namespace Files.Core.Windows; + +internal static class WindowsShellSearchFolderFactory +{ + private const ushort LanguageUserDefault = 0x0400; + private const string SystemIndexCatalog = "SystemIndex"; + private const STRUCTURED_QUERY_RESOLVE_OPTION ResolveOptions = STRUCTURED_QUERY_RESOLVE_OPTION.SQRO_DONT_RESOLVE_DATETIME | + STRUCTURED_QUERY_RESOLVE_OPTION.SQRO_DONT_MAP_RELATIONS | STRUCTURED_QUERY_RESOLVE_OPTION.SQRO_ADD_ROBUST_ITEM_NAME; + + internal static IShellItem Create(string query, IReadOnlyList? scopeLocators) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query); + + var condition = ParseCondition(query); + var factory = SearchFolderItemFactory.CreateInstance(); + HRESULT hr; + if (scopeLocators is not null) + { + var scope = WindowsShellItemArrayFactory.Create(scopeLocators); + hr = factory.SetScope(scope); + hr.ThrowOnFailure(); + } + + hr = factory.SetCondition(condition); + hr.ThrowOnFailure(); + hr = factory.GetShellItem(out IShellItem shellItem); + hr.ThrowOnFailure(); + + return shellItem; + } + + private static ICondition ParseCondition(string query) + { + var manager = QueryParserManager.CreateInstance(); + var interfaceId = typeof(IQueryParser).GUID; + var hr = manager.CreateLoadedParser(SystemIndexCatalog, LanguageUserDefault, in interfaceId, out var parser); + hr.ThrowOnFailure(); + if (parser is null) + { + throw new COMException("The Windows query parser manager returned no parser.", HRESULT.E_NOINTERFACE); + } + + hr = manager.InitializeOptions(false, true, parser); + hr.ThrowOnFailure(); + hr = parser.Parse(query, null, out var solution); + hr.ThrowOnFailure(); + if (solution is null) + { + throw new COMException("The Windows query parser returned no solution.", HRESULT.E_FAIL); + } + + hr = solution.GetQuery(out var condition, out _); + hr.ThrowOnFailure(); + if (condition is null) + { + throw new COMException("The Windows query parser returned no condition.", HRESULT.E_FAIL); + } + + var factory = solution as IConditionFactory2; + if (factory is null) + { + throw new COMException("The Windows query solution does not support condition resolution.", HRESULT.E_NOINTERFACE); + } + + hr = factory.ResolveCondition(condition, ResolveOptions, null, out ICondition resolvedCondition); + hr.ThrowOnFailure(); + + return resolvedCondition; + } +} diff --git a/src/Files.Core/Windows/WindowsFilesCoreBuilderExtensions.cs b/src/Files.Core/Windows/WindowsFilesCoreBuilderExtensions.cs index 4ce823f..fb78f2a 100644 --- a/src/Files.Core/Windows/WindowsFilesCoreBuilderExtensions.cs +++ b/src/Files.Core/Windows/WindowsFilesCoreBuilderExtensions.cs @@ -45,6 +45,7 @@ public static FilesCoreBuilder AddWindowsStorage( try { builder.AddStorageSource(windowsSource).AddStorageOperationHandler(new WindowsStorageOperationHandler(windowsSource)); + builder.AddStorageBrowseLocationHandler(workspace => new WindowsSearchBrowseLocationHandler(workspace, windowsSource)); } catch (Exception registrationError) when (source is null) { diff --git a/src/Files.Core/Windows/WindowsStorageSource.cs b/src/Files.Core/Windows/WindowsStorageSource.cs index 83220ef..d2a649c 100644 --- a/src/Files.Core/Windows/WindowsStorageSource.cs +++ b/src/Files.Core/Windows/WindowsStorageSource.cs @@ -84,6 +84,29 @@ public WindowsStorageSource(StorageSourceId? sourceId = null, string displayName DragDrop = new WindowsShellDragDropService(this); } + internal async Task CreateSearchFolderAsync(string query, StorableReference? scope, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query); + + if (scope is null) + { + return await _storableFactory.CreateSearchFolderAsync(query, null, cancellationToken).ConfigureAwait(false); + } + + if (scope.SourceId != SourceId) + { + throw new ArgumentException($"Search scope belongs to storage source '{scope.SourceId}'.", nameof(scope)); + } + + var scopeItem = await ResolveAsync(scope, cancellationToken).ConfigureAwait(false); + if (scopeItem is not WindowsFolder folder) + { + throw new InvalidOperationException($"Search scope '{scope.ItemId}' is not a Windows Shell folder."); + } + + return await _storableFactory.CreateSearchFolderAsync(query, [folder.Locator], cancellationToken).ConfigureAwait(false); + } + internal Task TryCreateFromAbsolutePidlAsync(ReadOnlyMemory absolutePidl, CancellationToken cancellationToken = default) { return _storableFactory.TryCreateFromAbsolutePidlAsync(absolutePidl, cancellationToken); diff --git a/src/Files/Adapters/BrowsePresentationAdapter.cs b/src/Files/Adapters/BrowsePresentationAdapter.cs index 46d7b53..240eb9c 100644 --- a/src/Files/Adapters/BrowsePresentationAdapter.cs +++ b/src/Files/Adapters/BrowsePresentationAdapter.cs @@ -1613,7 +1613,7 @@ private void StartColumnsLoad() } var session = _pane.BrowseSession; - if (session.Context is not FolderBrowseLocationContext context || session.Generation is 0) + if (session.Context is not IWindowsShellColumnProvider context || session.Generation is 0) { CancelColumnsLoad(); QueueColumns(session.Generation, CreateFallbackColumns()); @@ -1789,7 +1789,7 @@ private void CancelColumnsLoad() load?.Cancel(); } - private bool IsCurrentColumnsContext(FolderBrowseLocationContext context, long generation, CancellationToken cancellationToken) + private bool IsCurrentColumnsContext(IWindowsShellColumnProvider context, long generation, CancellationToken cancellationToken) { return !cancellationToken.IsCancellationRequested && _pane.BrowseSession.Generation == generation && @@ -2203,7 +2203,7 @@ private void QueuePendingThumbnail(StorableKey key, ThumbnailResult thumbnail) private CancellationTokenSource CreateLinkedCancellation(CancellationToken cancellationToken) => CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); - internal Task NavigateToLocationAsync(BrowseLocation location, CancellationToken cancellationToken) + internal Task NavigateToLocationAsync(BrowseLocation location, CancellationToken cancellationToken, PaneNavigationMode mode = PaneNavigationMode.Push) { EnsureActive(); @@ -2212,7 +2212,7 @@ internal Task NavigateToLocationAsync(BrowseLocation location, CancellationToken LocationNavigation navigation; lock (_locationNavigationLock) { - if (_locationNavigation is { } activeNavigation && Equals(activeNavigation.Location, location)) + if (_locationNavigation is { } activeNavigation && Equals(activeNavigation.Location, location) && activeNavigation.Mode == mode) { return WaitForLocationNavigationAsync(activeNavigation, cancellationToken); } @@ -2222,8 +2222,8 @@ internal Task NavigateToLocationAsync(BrowseLocation location, CancellationToken return Task.CompletedTask; } - var task = _pane.NavigateAsync(location, cancellationToken: _lifetime.Token, ownerWindowHandle: _ownerWindowHandle).AsTask(); - navigation = new LocationNavigation(location, task); + var task = _pane.NavigateAsync(location, mode, _lifetime.Token, _ownerWindowHandle).AsTask(); + navigation = new LocationNavigation(location, mode, task); _locationNavigation = navigation; } @@ -2232,6 +2232,15 @@ internal Task NavigateToLocationAsync(BrowseLocation location, CancellationToken return WaitForLocationNavigationAsync(navigation, cancellationToken); } + internal Task NavigateToSearchOriginAsync(BrowseLocation location, CancellationToken cancellationToken) + { + EnsureActive(); + + ArgumentNullException.ThrowIfNull(location); + + return NavigateToLocationAsync(location, cancellationToken, PaneNavigationMode.ExitSearch); + } + private static Task WaitForLocationNavigationAsync(LocationNavigation navigation, CancellationToken cancellationToken) { return cancellationToken.CanBeCanceled ? navigation.Task.WaitAsync(cancellationToken) : navigation.Task; @@ -2287,6 +2296,7 @@ FolderLocation folder when folder.Folder.LastKnownAddress is => value, FolderLocation folder => folder.Folder.LastKnownAddress?.ToString() ?? folder.Folder.ItemId, + SearchLocation search => search.Query, _ => location?.GetType().Name ?? _text.Home, }; } @@ -2317,9 +2327,9 @@ private sealed record PendingState(long Generation, bool IsLoading, string? Erro private sealed record PendingStatusBarSize(long Generation, long Version, ulong? Size); - private sealed record LocationNavigation(BrowseLocation Location, Task Task); + private sealed record LocationNavigation(BrowseLocation Location, PaneNavigationMode Mode, Task Task); - private sealed record ColumnCache(FolderBrowseLocationContext Context, long Generation, WindowsShellColumnSet? ColumnSet); + private sealed record ColumnCache(IWindowsShellColumnProvider Context, long Generation, WindowsShellColumnSet? ColumnSet); private sealed record ThumbnailApplyState(long Version, BrowseItemViewModel Target, ThumbnailResult? Thumbnail); @@ -2432,7 +2442,7 @@ private sealed class ColumnLoad : IDisposable private readonly CancellationTokenSource _cancellation; private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); - public FolderBrowseLocationContext Context { get; } + public IWindowsShellColumnProvider Context { get; } public long Generation { get; } @@ -2442,7 +2452,7 @@ private sealed class ColumnLoad : IDisposable public Task Task => _completion.Task; - public ColumnLoad(FolderBrowseLocationContext context, long generation, CancellationToken lifetimeToken) + public ColumnLoad(IWindowsShellColumnProvider context, long generation, CancellationToken lifetimeToken) { Context = context; Generation = generation; diff --git a/src/Files/Commands/AppCommandRegistration.cs b/src/Files/Commands/AppCommandRegistration.cs index b78a5e6..d9a8499 100644 --- a/src/Files/Commands/AppCommandRegistration.cs +++ b/src/Files/Commands/AppCommandRegistration.cs @@ -31,6 +31,7 @@ private static void RegisterNavigation(CommandRegistryBuilder builder) builder.Register(new(CommandIds.NavigateUp, Strings.Up, "Navigation.Up", Strings.Navigation, 30, "\uE74A"), static _ => new NavigationCommandHandler(CommandIds.NavigateUp)); builder.Register(new(CommandIds.NavigateHome, Strings.Home, "Navigation.Home", Strings.Navigation, 40, "\uE80F"), static _ => new NavigationCommandHandler(CommandIds.NavigateHome)); builder.Register(new(CommandIds.NavigatePath, Strings.Address, "Navigation.Path", Strings.Navigation, 50), static _ => new NavigationCommandHandler(CommandIds.NavigatePath)); + builder.Register(new(CommandIds.Search, Strings.Search, "App.ThemedIcons.Omnibar.Search", Strings.Navigation, 55), static _ => new NavigationCommandHandler(CommandIds.Search)); builder.Register(new(CommandIds.Refresh, Strings.Refresh, "Navigation.Refresh", Strings.Navigation, 60, "\uE72C"), static _ => new NavigationCommandHandler(CommandIds.Refresh)); builder.Register(new(CommandIds.OpenItem, Strings.Open, "Item.Open", Strings.Item, 10), static root => new OpenItemCommandHandler(root.ItemActivationService)); } diff --git a/src/Files/Commands/CommandContext.cs b/src/Files/Commands/CommandContext.cs index 3658456..1b5dab5 100644 --- a/src/Files/Commands/CommandContext.cs +++ b/src/Files/Commands/CommandContext.cs @@ -15,6 +15,8 @@ public sealed record CommandContext(RootViewModel Root, object? Parameter = null public string? Path => Parameter as string; + public string? Query => Parameter as string; + public BrowseItemViewModel? InvokedItem => Parameter switch { diff --git a/src/Files/Commands/CommandIds.cs b/src/Files/Commands/CommandIds.cs index d774725..4a8501d 100644 --- a/src/Files/Commands/CommandIds.cs +++ b/src/Files/Commands/CommandIds.cs @@ -23,6 +23,8 @@ public static class CommandIds public static readonly CommandId NavigatePath = new("files.navigation.path"); + public static readonly CommandId Search = new("files.navigation.search"); + public static readonly CommandId Refresh = new("files.navigation.refresh"); diff --git a/src/Files/Commands/Handlers/NavigationCommandHandler.cs b/src/Files/Commands/Handlers/NavigationCommandHandler.cs index df0fb50..aa156d8 100644 --- a/src/Files/Commands/Handlers/NavigationCommandHandler.cs +++ b/src/Files/Commands/Handlers/NavigationCommandHandler.cs @@ -10,8 +10,7 @@ internal sealed class NavigationCommandHandler(CommandId id) : ICommandHandler { public CommandId Id => id; - public CommandConcurrencyPolicy ConcurrencyPolicy => - CommandConcurrencyPolicy.CancelPrevious; + public CommandConcurrencyPolicy ConcurrencyPolicy => id == CommandIds.Search ? CommandConcurrencyPolicy.AllowParallel : CommandConcurrencyPolicy.CancelPrevious; public CommandStateInvalidation StateDependencies => CommandStateInvalidation.ActiveTab | @@ -37,10 +36,11 @@ public CommandState GetState(CommandContext context) browser.CanGoUp, var commandId when commandId == CommandIds.NavigatePath => true, + var commandId when commandId == CommandIds.Search => browser.CanSearch, _ => true, }; - return new(true, isAvailable && !browser.IsLoading); + return new(true, isAvailable && (!browser.IsLoading || id == CommandIds.Search)); } public async ValueTask ExecuteAsync(CommandContext context, CancellationToken cancellationToken = default) @@ -73,6 +73,14 @@ await browser.NavigateHomeAsync(cancellationToken) await browser.NavigateToPathAsync(context.Path, cancellationToken).ConfigureAwait(false); break; + case var commandId when commandId == CommandIds.Search: + if (context.Parameter is not string query) + { + return CommandExecutionResult.Failed(new ArgumentException(Strings.SearchQueryRequired.GetLocalized(), nameof(context.Query))); + } + + await browser.SearchAsync(query, cancellationToken).ConfigureAwait(false); + break; case var commandId when commandId == CommandIds.Refresh: await browser.RefreshAsync(cancellationToken).ConfigureAwait(false); break; diff --git a/src/Files/Strings/en-US/Resources.resw b/src/Files/Strings/en-US/Resources.resw index 0e4dafd..533c345 100644 --- a/src/Files/Strings/en-US/Resources.resw +++ b/src/Files/Strings/en-US/Resources.resw @@ -98,6 +98,8 @@ This folder is empty. File A folder path is required. + A search query is required. + No items match your search. {0} item {0} items {0} item selected @@ -109,6 +111,7 @@ Forward Up Address + Search Refresh Open Copy diff --git a/src/Files/ViewModels/FolderBrowserViewModel.cs b/src/Files/ViewModels/FolderBrowserViewModel.cs index 3e8ae99..b2ee199 100644 --- a/src/Files/ViewModels/FolderBrowserViewModel.cs +++ b/src/Files/ViewModels/FolderBrowserViewModel.cs @@ -147,6 +147,14 @@ public IReadOnlyList SelectedItems public bool CanShowNew => !IsLoading && !IsBusy && _shellNewMenu is not null && TryGetCurrentFileSystemFolder(out _); + public bool CanSearch => _windowsSource is not null && Location switch + { + HomeLocation => true, + FolderLocation folder => folder.Folder.SourceId == _windowsSource.SourceId, + SearchLocation search => search.Scope is null || search.Scope.SourceId == _windowsSource.SourceId, + _ => false, + }; + internal bool SupportsItemSelection => Location is not null and not HomeLocation; internal bool CanSelectAllItems => SupportsItemSelection && !IsBusy && Items.Count > SelectedKeys.Count; @@ -157,6 +165,8 @@ public IReadOnlyList SelectedItems public string LocationText => _browseAdapter.LocationText; + public string SearchText => Location is SearchLocation search ? search.Query : string.Empty; + public BrowseLocation? Location => _pane.Location; public string LocationDisplayName => _pane.BrowseSession.Context?.LocationModel?.Name ?? LocationText; @@ -167,6 +177,8 @@ public IReadOnlyList SelectedItems public bool IsFolderEmpty => !IsLoading && Location is not null && Items.Count is 0 && _browseAdapter.ErrorMessage is null; + public string EmptyMessage => Location is SearchLocation ? Strings.NoSearchResults.GetLocalized() : Strings.FolderEmpty.GetLocalized(); + public bool IsBusy => _browseAdapter.IsBusy; public bool CanGoBack => _browseAdapter.CanGoBack; @@ -241,6 +253,33 @@ public Task NavigateToItemAsync(BrowseItemViewModel item, CancellationToken canc public Task NavigateToReferenceAsync(StorableReference reference, CancellationToken cancellationToken = default) => _browseAdapter.NavigateToReferenceAsync(reference, cancellationToken); + public Task SearchAsync(string query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (_windowsSource is null) + { + throw new NotSupportedException("Windows Shell search is not available."); + } + + if (string.IsNullOrWhiteSpace(query)) + { + var origin = Location is SearchLocation search ? search.Scope is { } originScope ? new FolderLocation(originScope) : HomeLocation.Instance : Location; + + return origin is null ? Task.CompletedTask : _browseAdapter.NavigateToSearchOriginAsync(origin, cancellationToken); + } + + var scope = Location switch + { + HomeLocation => null, + FolderLocation folder when folder.Folder.SourceId == _windowsSource.SourceId => folder.Folder, + SearchLocation search when search.Scope is null || search.Scope.SourceId == _windowsSource.SourceId => search.Scope, + _ => throw new NotSupportedException("The current location does not support Windows Shell search."), + }; + + return _browseAdapter.NavigateToLocationAsync(new SearchLocation(query, scope), cancellationToken, PaneNavigationMode.UpdateSearch); + } + public Task GoBackAsync(CancellationToken cancellationToken = default) => _browseAdapter.GoBackAsync(cancellationToken); @@ -997,6 +1036,9 @@ private void BrowseAdapter_Updated(object? sender, CoreBrowseUpdatedEventArgs ar OnPropertyChanged(nameof(Location)); OnPropertyChanged(nameof(LocationText)); OnPropertyChanged(nameof(LocationDisplayName)); + OnPropertyChanged(nameof(SearchText)); + OnPropertyChanged(nameof(EmptyMessage)); + OnPropertyChanged(nameof(CanSearch)); OnPropertyChanged(nameof(CanShowNew)); RefreshLocationIcon(); } diff --git a/src/Files/ViewModels/NavigationToolbarViewModel.cs b/src/Files/ViewModels/NavigationToolbarViewModel.cs index 27895d7..e7c3c7d 100644 --- a/src/Files/ViewModels/NavigationToolbarViewModel.cs +++ b/src/Files/ViewModels/NavigationToolbarViewModel.cs @@ -15,6 +15,12 @@ public sealed class NavigationToolbarViewModel : ObservableObject, IDisposable private CancellationTokenSource? _breadcrumbCancellation; + private string _searchText = string.Empty; + + private long _searchRequestGeneration; + + private bool _isSearchRequestActive; + private int _isDisposed; public CommandBindingViewModel ToggleSidebarCommand { get; } @@ -29,6 +35,8 @@ public sealed class NavigationToolbarViewModel : ObservableObject, IDisposable public CommandBindingViewModel NavigatePathCommand { get; } + public CommandBindingViewModel SearchCommand { get; } + public CommandBindingViewModel RefreshCommand { get; } public StatusCenterViewModel StatusCenter { get; } @@ -37,6 +45,8 @@ public sealed class NavigationToolbarViewModel : ObservableObject, IDisposable public string LocationText => _activeFolderBrowser?.LocationText ?? string.Empty; + public string SearchText => _searchText; + internal FolderBrowserViewModel? ActiveFolderBrowser => _activeFolderBrowser; internal NavigationToolbarViewModel( @@ -46,15 +56,24 @@ internal NavigationToolbarViewModel( CommandBindingViewModel upCommand, CommandBindingViewModel homeCommand, CommandBindingViewModel navigatePathCommand, + CommandBindingViewModel searchCommand, CommandBindingViewModel refreshCommand, StatusCenterViewModel statusCenter) { ArgumentNullException.ThrowIfNull(toggleSidebarCommand); + ArgumentNullException.ThrowIfNull(backCommand); + ArgumentNullException.ThrowIfNull(forwardCommand); + ArgumentNullException.ThrowIfNull(upCommand); + ArgumentNullException.ThrowIfNull(homeCommand); + ArgumentNullException.ThrowIfNull(navigatePathCommand); + + ArgumentNullException.ThrowIfNull(searchCommand); + ArgumentNullException.ThrowIfNull(refreshCommand); ArgumentNullException.ThrowIfNull(statusCenter); @@ -65,6 +84,7 @@ internal NavigationToolbarViewModel( UpCommand = upCommand; HomeCommand = homeCommand; NavigatePathCommand = navigatePathCommand; + SearchCommand = searchCommand; RefreshCommand = refreshCommand; StatusCenter = statusCenter; } @@ -78,6 +98,8 @@ public void Dispose() _activeFolderBrowser?.PropertyChanged -= ActiveFolderBrowser_PropertyChanged; _activeFolderBrowser = null; + _searchRequestGeneration++; + _isSearchRequestActive = false; _breadcrumbCancellation?.Cancel(); _breadcrumbCancellation = null; StatusCenter.Dispose(); @@ -93,11 +115,44 @@ internal void SetActiveFolderBrowser(FolderBrowserViewModel? value) _activeFolderBrowser?.PropertyChanged -= ActiveFolderBrowser_PropertyChanged; _activeFolderBrowser = value; _activeFolderBrowser?.PropertyChanged += ActiveFolderBrowser_PropertyChanged; + _searchRequestGeneration++; + _isSearchRequestActive = false; OnPropertyChanged(nameof(LocationText)); + SetSearchText(_activeFolderBrowser?.SearchText ?? string.Empty); _ = RefreshBreadcrumbItemsAsync(); } + internal async Task ExecuteSearchAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var browser = _activeFolderBrowser; + var generation = ++_searchRequestGeneration; + + try + { + _isSearchRequestActive = true; + SetSearchText(query); + + return await SearchCommand.ExecuteAsync(query); + } + catch (Exception error) + { + UiDiagnosticLog.Write("NavigationToolbar", $"Search command failed: {error.Message}"); + + return CommandExecutionResult.Failed(error); + } + finally + { + if (generation == _searchRequestGeneration && ReferenceEquals(_activeFolderBrowser, browser)) + { + _isSearchRequestActive = false; + SetSearchText(browser?.SearchText ?? string.Empty); + } + } + } + internal Task NavigateToBreadcrumbAsync(NavigationToolbarBreadcrumbItem item, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(item); @@ -122,12 +177,19 @@ private void ActiveFolderBrowser_PropertyChanged(object? sender, PropertyChanged OnPropertyChanged(nameof(LocationText)); } + if (!_isSearchRequestActive && (e.PropertyName is null or nameof(FolderBrowserViewModel.SearchText))) + { + SetSearchText(_activeFolderBrowser?.SearchText ?? string.Empty); + } + if (e.PropertyName is null or nameof(FolderBrowserViewModel.Location) or nameof(FolderBrowserViewModel.ShowHiddenItems)) { _ = RefreshBreadcrumbItemsAsync(); } } + private void SetSearchText(string value) => SetProperty(ref _searchText, value, nameof(SearchText)); + private async Task RefreshBreadcrumbItemsAsync() { var browser = _activeFolderBrowser; diff --git a/src/Files/ViewModels/RootViewModel.cs b/src/Files/ViewModels/RootViewModel.cs index 8cee204..fdb7387 100644 --- a/src/Files/ViewModels/RootViewModel.cs +++ b/src/Files/ViewModels/RootViewModel.cs @@ -100,6 +100,8 @@ public SidebarDisplayMode SidebarDisplayMode public CommandBindingViewModel NavigatePathCommand => _commandManager.GetBinding(CommandIds.NavigatePath); + public CommandBindingViewModel SearchCommand => _commandManager.GetBinding(CommandIds.Search); + public CommandBindingViewModel RefreshCommand => _commandManager.GetBinding(CommandIds.Refresh); public CommandBindingViewModel CopyCommand => _commandManager.GetBinding(CommandIds.Copy); @@ -219,7 +221,16 @@ internal RootViewModel(WindowSession window, WindowPresentationFactory presentat SplitPaneVerticalCommand, SplitPaneHorizontalCommand, SetActiveTabAt); - NavigationToolbar = new(ToggleSidebarCommand, BackCommand, ForwardCommand, UpCommand, HomeCommand, NavigatePathCommand, RefreshCommand, presentationFactory.CreateStatusCenterViewModel()); + NavigationToolbar = new( + ToggleSidebarCommand, + BackCommand, + ForwardCommand, + UpCommand, + HomeCommand, + NavigatePathCommand, + SearchCommand, + RefreshCommand, + presentationFactory.CreateStatusCenterViewModel()); Toolbar = new( CopyCommand, CutCommand, diff --git a/src/Files/Views/FolderBrowser.xaml b/src/Files/Views/FolderBrowser.xaml index 595d3e3..04094a7 100644 --- a/src/Files/Views/FolderBrowser.xaml +++ b/src/Files/Views/FolderBrowser.xaml @@ -39,7 +39,7 @@ VerticalAlignment="Top" IsHitTestVisible="False" Opacity="0.7" - Text="{localization:Localized ResourceKey=FolderEmpty}" + Text="{x:Bind ViewModel.EmptyMessage, Mode=OneWay}" TextAlignment="Center" TextWrapping="Wrap" Visibility="{x:Bind ViewModel.IsFolderEmpty, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> diff --git a/src/Files/Views/NavigationToolbar.xaml b/src/Files/Views/NavigationToolbar.xaml index 6fad4d6..2f62abf 100644 --- a/src/Files/Views/NavigationToolbar.xaml +++ b/src/Files/Views/NavigationToolbar.xaml @@ -9,6 +9,11 @@ xmlns:viewModels="using:Files.ViewModels" xmlns:views="using:Files.Views" x:Name="Root"> + + + + + @@ -17,6 +22,7 @@ + @@ -108,7 +114,6 @@ - + + + + + + + + + + + FolderViewKeyboardFocusRequested?.Invoke(this, EventArgs.Empty)); + await searchTask; + } + + private async void SearchOmnibar_TextChanged(Omnibar sender, OmnibarTextChangedEventArgs args) + { + if (args.Reason is OmnibarTextChangeReason.ProgrammaticChange or OmnibarTextChangeReason.SuggestionChosen || !sender.IsEnabled || ViewModel is not { } viewModel) + { + return; + } + + var text = args.Mode.Text ?? string.Empty; + if (args.Reason is OmnibarTextChangeReason.None && string.Equals(text, viewModel.SearchText, StringComparison.Ordinal)) + { + return; + } + + await viewModel.ExecuteSearchAsync(text); + } + + private void SearchKeyboardAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + if (!SearchOmnibar.IsEnabled) + { + return; + } + + SearchOmnibar.FocusTextBox(); + args.Handled = true; + } + private async void PathBreadcrumbBar_ItemClicked(Files.Controls.BreadcrumbBar sender, Files.Controls.BreadcrumbBarItemClickedEventArgs args) { if (ViewModel is not { } viewModel) diff --git a/src/Files/Views/PaneHost.xaml.cs b/src/Files/Views/PaneHost.xaml.cs index d2a42b8..27d2cf1 100644 --- a/src/Files/Views/PaneHost.xaml.cs +++ b/src/Files/Views/PaneHost.xaml.cs @@ -35,7 +35,7 @@ public PaneHost() Unloaded += PaneHost_Unloaded; } - internal bool FocusActiveFolderView() + internal bool FocusActiveFolderView(FocusState focusState = FocusState.Pointer) { if (ViewModel?.ActivePane is not { } activePane || !_paneViews.TryGetValue(activePane.Id, out var paneView)) { @@ -44,7 +44,7 @@ internal bool FocusActiveFolderView() var itemsView = paneView.FindDescendant(); - return itemsView?.Focus(FocusState.Pointer) is true; + return itemsView?.Focus(focusState) is true; } private static void ViewModelChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) diff --git a/src/Files/Views/RootView.xaml.cs b/src/Files/Views/RootView.xaml.cs index 7bdfcf0..f1503fb 100644 --- a/src/Files/Views/RootView.xaml.cs +++ b/src/Files/Views/RootView.xaml.cs @@ -45,6 +45,7 @@ public RootView(RootViewModel viewModel, IWindowsShellPreviewSessionFactory? pre PreviewPaneView.SessionFactory = previewSessionFactory; TabStrip.NewWindowRequested += TabStrip_NewWindowRequested; NavigationToolbarView.FolderViewFocusRequested += NavigationToolbarView_FolderViewFocusRequested; + NavigationToolbarView.FolderViewKeyboardFocusRequested += NavigationToolbarView_FolderViewKeyboardFocusRequested; Loaded += RootView_Loaded; } @@ -82,6 +83,7 @@ public async ValueTask DisposeAsync() _viewModel.OperationErrorReported -= ViewModel_OperationErrorReported; TabStrip.NewWindowRequested -= TabStrip_NewWindowRequested; NavigationToolbarView.FolderViewFocusRequested -= NavigationToolbarView_FolderViewFocusRequested; + NavigationToolbarView.FolderViewKeyboardFocusRequested -= NavigationToolbarView_FolderViewKeyboardFocusRequested; _pendingErrorMessages.Clear(); _activeErrorDialog?.Hide(); await _showErrorDialogsTask; @@ -96,6 +98,8 @@ private void TabStrip_NewWindowRequested(object? sender, EventArgs e) => private void NavigationToolbarView_FolderViewFocusRequested(object? sender, EventArgs e) => PaneHostView.FocusActiveFolderView(); + private void NavigationToolbarView_FolderViewKeyboardFocusRequested(object? sender, EventArgs e) => PaneHostView.FocusActiveFolderView(FocusState.Keyboard); + private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is null or nameof(RootViewModel.ActiveTab) or nameof(RootViewModel.ActiveFolderBrowser)) diff --git a/tests/Files.UnitTests/BrowseSessionTests.cs b/tests/Files.UnitTests/BrowseSessionTests.cs index 6d6b71f..c726d5c 100644 --- a/tests/Files.UnitTests/BrowseSessionTests.cs +++ b/tests/Files.UnitTests/BrowseSessionTests.cs @@ -92,6 +92,41 @@ public async Task PublishesFirstBatchBeforeEnumerationCompletes() Assert.AreEqual(600, session.Items.Count); } + /// + /// Test case: publishes the first search result before enumeration completes. + /// + /// A task that represents the asynchronous test. + [TestMethod] + public async Task SearchPublishesFirstResultBeforeEnumerationCompletes() + { + var factory = new TestModelFactory(); + var items = Enumerable.Range(0, 2).Select(index => factory.CreateModel($"item-{index}", $"Item {index}", out _)).Cast().ToArray(); + var providerPaused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var providerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resolver = new TestBrowseLocationResolver(items) + { + BeforeYieldAsync = async (index, cancellationToken) => + { + if (index is 1) + { + providerPaused.TrySetResult(true); + await providerRelease.Task.WaitAsync(cancellationToken); + } + }, + }; + using var session = new BrowseSession(resolver); + var navigation = session.NavigateAsync(new SearchLocation("query")).AsTask(); + + await providerPaused.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.IsTrue(session.IsLoading); + Assert.AreEqual(1, session.Items.Count); + Assert.IsFalse(navigation.IsCompleted); + providerRelease.TrySetResult(true); + await navigation; + Assert.AreEqual(2, session.Items.Count); + } + /// /// Test case: sorts the first page and applies the requested sort after progressive enumeration. /// diff --git a/tests/Files.UnitTests/SessionTests.cs b/tests/Files.UnitTests/SessionTests.cs index 6fdd044..22c11c6 100644 --- a/tests/Files.UnitTests/SessionTests.cs +++ b/tests/Files.UnitTests/SessionTests.cs @@ -46,6 +46,51 @@ public async Task PaneNavigationCommitsHistoryAndDropsForwardBranch() Assert.IsFalse(pane.CanGoForward); } + /// + /// Test case: live search replaces its history entry and clearing the query returns to the search origin. + /// + /// A task that represents the asynchronous test. + [TestMethod] + public async Task SearchNavigationReplacesQueryAndReturnsToOrigin() + { + var resolver = new TestBrowseLocationResolver([]); + await using var paneOwner = new BrowsePaneSessionFactory(resolver).Create(); + var pane = GetBrowsePane(paneOwner); + var home = HomeLocation.Instance; + var firstSearch = new SearchLocation("first"); + var secondSearch = new SearchLocation("second"); + + await pane.NavigateAsync(home); + resolver.BlockEnumeration = true; + resolver.EnumerationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstNavigation = pane.NavigateAsync(firstSearch, PaneNavigationMode.UpdateSearch).AsTask(); + await resolver.EnumerationStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + resolver.BlockEnumeration = false; + var secondNavigation = pane.NavigateAsync(secondSearch, PaneNavigationMode.UpdateSearch).AsTask(); + + await secondNavigation.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.ThrowsAsync(async () => await firstNavigation); + + CollectionAssert.AreEqual(new BrowseLocation[] {home, secondSearch}, pane.History.Entries.ToArray()); + Assert.AreEqual(secondSearch, pane.Location); + + resolver.BlockEnumeration = true; + resolver.EnumerationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thirdNavigation = pane.NavigateAsync(new SearchLocation("third"), PaneNavigationMode.UpdateSearch).AsTask(); + await resolver.EnumerationStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + resolver.BlockEnumeration = false; + var exitNavigation = pane.NavigateAsync(home, PaneNavigationMode.ExitSearch).AsTask(); + + await exitNavigation.WaitAsync(TimeSpan.FromSeconds(5)); + await Assert.ThrowsAsync(async () => await thirdNavigation); + + CollectionAssert.AreEqual(new BrowseLocation[] {home, secondSearch}, pane.History.Entries.ToArray()); + Assert.AreEqual(home, pane.Location); + Assert.IsTrue(pane.CanGoForward); + Assert.IsTrue(await pane.GoForwardAsync()); + Assert.AreEqual(secondSearch, pane.Location); + } + /// /// Test case: interactive pane navigation passes its owner only to that enumeration. /// diff --git a/tests/Files.UnitTests/WindowsShellPreviewTests.cs b/tests/Files.UnitTests/WindowsShellPreviewTests.cs index 69a8a29..b7a47e4 100644 --- a/tests/Files.UnitTests/WindowsShellPreviewTests.cs +++ b/tests/Files.UnitTests/WindowsShellPreviewTests.cs @@ -783,6 +783,8 @@ public Task InvokeAsync(Func action, CancellationToken cancellationToke public Task InvokeConcurrentAsync(Func action, CancellationToken cancellationToken = default) => Invoke(action, cancellationToken); + public Task InvokeSearchAsync(Func action, CancellationToken cancellationToken = default) => Invoke(action, cancellationToken); + public Task InvokeOperationAsync(Func action, CancellationToken cancellationToken = default) => Invoke(action, cancellationToken); diff --git a/tests/Files.UnitTests/WindowsShellSchedulerTests.cs b/tests/Files.UnitTests/WindowsShellSchedulerTests.cs index 650d932..a568560 100644 --- a/tests/Files.UnitTests/WindowsShellSchedulerTests.cs +++ b/tests/Files.UnitTests/WindowsShellSchedulerTests.cs @@ -81,6 +81,40 @@ await scheduler.InvokeConcurrentAsync(() => Assert.AreEqual(ApartmentState.STA, apartmentStates.Distinct().Single()); } + /// + /// Test case: blocked search work does not occupy the concurrent lane. + /// + /// A task that represents the asynchronous test. + [TestMethod] + public async Task BlockedSearchDoesNotOccupyConcurrentLane() + { + await using var scheduler = new WindowsShellScheduler(concurrentWorkerCount: 2); + using var searchRelease = new ManualResetEventSlim(false); + var searchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var searchWork = scheduler.InvokeSearchAsync(() => + { + searchStarted.TrySetResult(Thread.CurrentThread.ManagedThreadId); + searchRelease.Wait(); + + return true; + }); + + try + { + var searchThreadId = await searchStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var concurrentThreadId = await scheduler.InvokeConcurrentAsync(() => Thread.CurrentThread.ManagedThreadId).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.AreNotEqual(searchThreadId, concurrentThreadId); + Assert.IsFalse(searchWork.IsCompleted); + } + finally + { + searchRelease.Set(); + } + + Assert.IsTrue(await searchWork); + } + /// /// Test case: nested ordered invocation runs without deadlock. /// diff --git a/tests/Files.UnitTests/WindowsShellSearchTests.cs b/tests/Files.UnitTests/WindowsShellSearchTests.cs new file mode 100644 index 0000000..12ac73c --- /dev/null +++ b/tests/Files.UnitTests/WindowsShellSearchTests.cs @@ -0,0 +1,91 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +using Files.Core.Browsing; +using Files.Core.Composition; +using Files.Core.Storage; +using Files.Core.Windows; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; + +namespace Files.UnitTests; + +/// Contains tests for Windows Shell search behavior. +[TestClass] +[DoNotParallelize] +public sealed class WindowsShellSearchTests +{ + /// Test case: the Shell continuation service exposes and updates its cancellation state. + [TestMethod] + public void QueryContinuationServiceReflectsCancellation() + { + using var cancellation = new CancellationTokenSource(); + var continuation = new WindowsShellQueryContinue(cancellation.Token); + var continuationId = typeof(IQueryContinue).GUID; + + Assert.AreEqual(HRESULT.S_OK, continuation.QueryContinue()); + Assert.AreEqual(HRESULT.S_OK, continuation.QueryService(in continuationId, in continuationId, out var service)); + Assert.AreSame(continuation, service); + + cancellation.Cancel(); + + Assert.AreEqual(HRESULT.S_FALSE, continuation.QueryContinue()); + var unsupportedId = Guid.Empty; + Assert.AreEqual(HRESULT.E_NOINTERFACE, continuation.QueryService(in unsupportedId, in continuationId, out service)); + Assert.IsNull(service); + } + + /// Test case: the Windows slice opens global and scoped search locations. + /// A task that represents the asynchronous test. + [TestMethod] + public async Task WindowsSliceOpensGlobalAndScopedSearchLocations() + { + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using var runtime = new FilesCoreBuilder().AddWindowsStorage(enablePreviews: false, enableArchives: false).Build(); + + var globalLocation = new SearchLocation($"unlikely-refiles-test-{Guid.NewGuid():N}"); + await using (var globalContext = await runtime.LocationResolver.OpenAsync(globalLocation, cancellation.Token)) + { + Assert.AreEqual(globalLocation, globalContext.Location); + Assert.IsNull(globalContext.LocationModel); + var parentResolver = Assert.IsInstanceOfType(globalContext); + Assert.AreEqual(HomeLocation.Instance, await parentResolver.GetParentLocationAsync(cancellation.Token)); + } + + var directoryPath = Path.Combine(Path.GetTempPath(), $"ReFiles.SearchTests.{Guid.NewGuid():N}"); + var filePath = Path.Combine(directoryPath, "windows-shell-search-result.txt"); + Directory.CreateDirectory(directoryPath); + await File.WriteAllTextAsync(filePath, "search test", cancellation.Token); + try + { + await using var scopeModel = await runtime.Workspace.ResolveAsync(new StorageAddress(WindowsStorageSource.FileAddressScheme, directoryPath), cancellation.Token); + var scopedLocation = new SearchLocation("System.FileName:windows-shell-search-result.txt", scopeModel.Reference); + await using var scopedContext = await runtime.LocationResolver.OpenAsync(scopedLocation, cancellation.Token); + var scopedParentResolver = Assert.IsInstanceOfType(scopedContext); + var parent = Assert.IsInstanceOfType(await scopedParentResolver.GetParentLocationAsync(cancellation.Token)); + + Assert.AreEqual(scopedLocation, scopedContext.Location); + Assert.AreEqual(scopeModel.Reference, parent.Folder); + + var foundMatch = false; + await foreach (var item in scopedContext.GetItemsAsync(cancellation.Token)) + { + await using (item) + { + foundMatch = string.Equals(filePath, item.Reference.LastKnownAddress?.Value, StringComparison.OrdinalIgnoreCase); + } + + if (foundMatch) + { + break; + } + } + + Assert.IsTrue(foundMatch, "The scoped Shell search did not return the matching file."); + } + finally + { + Directory.Delete(directoryPath, recursive: true); + } + } +}