From 623922c5ae438c050102c7d8e37f8c343e97ef6a Mon Sep 17 00:00:00 2001 From: yair100 <39923744+yair100@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:07:56 -0400 Subject: [PATCH 1/5] Code Quality: Improve search performance (#18992) --- .../Utils/Storage/Search/FolderSearch.cs | 544 +++++++++--------- src/Files.App/ViewModels/ShellViewModel.cs | 98 +++- 2 files changed, 372 insertions(+), 270 deletions(-) diff --git a/src/Files.App/Utils/Storage/Search/FolderSearch.cs b/src/Files.App/Utils/Storage/Search/FolderSearch.cs index b136464d8bdc..d128143c4454 100644 --- a/src/Files.App/Utils/Storage/Search/FolderSearch.cs +++ b/src/Files.App/Utils/Storage/Search/FolderSearch.cs @@ -3,7 +3,9 @@ using Files.Shared.Helpers; using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; using System.IO; +using System.IO.Enumeration; using System.Text.RegularExpressions; using Windows.Storage; using Windows.Storage.FileProperties; @@ -35,7 +37,18 @@ public sealed class FolderSearch private uint UsedMaxItemCount => MaxItemCount > 0 ? MaxItemCount : uint.MaxValue; - public EventHandler? SearchTick; + public DispatcherQueue DispatcherQueue { get; set; } = MainWindow.Instance.DispatcherQueue; + + /// + /// Raised on a throttle with the results found since the previous tick, on a background thread during Win32 walks. + /// + public event EventHandler>? SearchTick; + + private readonly IntervalSampler tickSampler = new(500); + private readonly HashSet indexedResultPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly List shortcutResults = []; + private List pendingResults = []; + private bool hasRaisedTick; private bool IsAQSQuery => Query is not null && (Query.StartsWith('$') || Query.Contains(':', StringComparison.Ordinal)); @@ -100,6 +113,21 @@ public async Task SearchAsync(IList results, CancellationToken token { App.Logger.LogWarning(e, "Search failure"); } + + try + { + if (MaxItemCount > 0) + await LoadSuggestionIconsAsync(results, token); + else + await ResolveShortcutTargetsAsync(token); + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + App.Logger.LogWarning(e, "Failed to finalize search results"); + } } private async Task AddItemsForHomeAsync(IList results, CancellationToken token) @@ -144,11 +172,41 @@ public async Task> SearchAsync() return results; } + private void AddResult(IList results, ListedItem item, CancellationToken token) + { + if (token.IsCancellationRequested) + return; + + results.Add(item); + pendingResults.Add(item); + if (item is ShortcutItem shortcutItem) + shortcutResults.Add(shortcutItem); + + RaiseSearchTickIfDue(token); + } + + private void RaiseSearchTickIfDue(CancellationToken token) + { + if (pendingResults.Count == 0 || token.IsCancellationRequested || (hasRaisedTick && !tickSampler.CheckNow())) + return; + + var batch = pendingResults; + pendingResults = []; + hasRaisedTick = true; + + SearchTick?.Invoke(this, batch); + } + + private uint GetRemainingItemCount(IList results) + => results.Count >= UsedMaxItemCount ? 0 : UsedMaxItemCount - (uint)results.Count; + private async Task SearchAsync(BaseStorageFolder folder, IList results, CancellationToken token) { - //var sampler = new IntervalSampler(500); uint index = 0; - var stepSize = Math.Min(defaultStepSize, UsedMaxItemCount); + var stepSize = Math.Min(defaultStepSize, GetRemainingItemCount(results)); + if (stepSize == 0) + return; + var options = ToQueryOptions(); var queryResult = folder.CreateItemQueryWithOptions(options); @@ -166,25 +224,79 @@ private async Task SearchAsync(BaseStorageFolder folder, IList resul try { if (!item.Name.StartsWith('.') || UserSettingsService.FoldersSettingsService.ShowDotFiles) - results.Add(await GetListedItemAsync(item)); + { + var listedItem = await GetListedItemAsync(item); + AddResult(results, listedItem, token); + if (listedItem.ItemPath is not null) + indexedResultPaths.Add(listedItem.ItemPath); + } } catch (Exception ex) { App.Logger.LogWarning(ex, "Error creating ListedItem from StorageItem"); } - - if (results.Count == 32 || results.Count % 300 == 0 /*|| sampler.CheckNow()*/) - { - SearchTick?.Invoke(this, EventArgs.Empty); - } } index += (uint)items.Count; - stepSize = Math.Min(defaultStepSize, UsedMaxItemCount - (uint)results.Count); + stepSize = Math.Min(defaultStepSize, GetRemainingItemCount(results)); + if (stepSize == 0) + return; + items = await queryResult.GetItemsAsync(index, stepSize).AsTask(token); } } + // Awaited so the final sort treats folder shortcuts as folders + private async Task ResolveShortcutTargetsAsync(CancellationToken token) + { + if (shortcutResults.Count == 0) + return; + + var links = new ShellLinkItem?[shortcutResults.Count]; + await Parallel.ForEachAsync( + Enumerable.Range(0, links.Length), + new ParallelOptions { CancellationToken = token, MaxDegreeOfParallelism = 4 }, + async (i, _) => links[i] = await FileOperationsHelpers.ParseLinkAsync(shortcutResults[i].GetRequiredPath(), resolveTarget: false)); + + await DispatcherQueue.EnqueueOrInvokeAsync(() => + { + for (var i = 0; i < links.Length; i++) + { + if (links[i] is not { } link) + continue; + + var shortcutItem = shortcutResults[i]; + shortcutItem.TargetPath = link.TargetPath; + shortcutItem.Arguments = link.Arguments; + shortcutItem.WorkingDirectory = link.WorkingDirectory; + shortcutItem.RunAsAdmin = link.RunAsAdmin; + shortcutItem.ShowWindowCommand = link.ShowWindowCommand; + shortcutItem.PrimaryItemAttribute = link.IsFolder ? StorageItemTypes.Folder : StorageItemTypes.File; + } + }); + } + + private Task LoadSuggestionIconsAsync(IList results, CancellationToken token) + { + return Task.WhenAll(results.Where(x => x.FileImage is null).Select(async item => + { + var iconResult = await FileThumbnailHelper.GetIconAsync( + item.GetRequiredPath(), + Constants.ShellIconSizes.Small, + item.PrimaryItemAttribute == StorageItemTypes.Folder, + IconOptions.ReturnIconOnly); + + if (iconResult is null || token.IsCancellationRequested) + return; + + await DispatcherQueue.EnqueueOrInvokeAsync(async () => + { + if (await iconResult.ToBitmapAsync() is { } bitmapImage) + item.FileImage = bitmapImage; + }); + })); + } + private async Task AddItemsForLibraryAsync(LibraryLocationItem library, IList results, CancellationToken token) { foreach (var folder in library.Folders) @@ -297,7 +409,6 @@ private bool MatchesTagExpression(IEnumerable? fileTags, TagQueryExpress private async Task SearchTagsAsync(string folder, IList results, CancellationToken token) { - //var sampler = new IntervalSampler(500); var expression = ParseTagQuery(AQSQuery); if (expression.OrGroups.Count == 0) @@ -350,8 +461,8 @@ private async Task SearchTagsAsync(string folder, IList results, Can if (shouldBeListed) { var item = GetListedItemAsync(match.FilePath, findData); - if (item is not null && !token.IsCancellationRequested) - results.Add(item); + if (item is not null) + AddResult(results, item, token); } } } @@ -366,9 +477,7 @@ private async Task SearchTagsAsync(string folder, IList results, Can ?? throw new InvalidOperationException($"The search item '{match.FilePath}' could not be opened."); if (!item.Name.StartsWith('.') || UserSettingsService.FoldersSettingsService.ShowDotFiles) { - var listedItem = await GetListedItemAsync(item); - if (!token.IsCancellationRequested) - results.Add(listedItem); + AddResult(results, await GetListedItemAsync(item), token); } } catch (Exception ex) @@ -376,14 +485,6 @@ private async Task SearchTagsAsync(string folder, IList results, Can App.Logger.LogWarning(ex, "Error creating ListedItem from StorageItem"); } } - - if (token.IsCancellationRequested) - return; - - if (results.Count == 32 || results.Count % 300 == 0 /*|| sampler.CheckNow()*/) - { - SearchTick?.Invoke(this, EventArgs.Empty); - } } } @@ -396,235 +497,211 @@ private async Task AddItemsAsync(string folder, IList results, Cance else { var workingFolder = await GetStorageFolderAsync(folder); + var storageFolder = workingFolder ? workingFolder.Result : null; - var hiddenOnlyFromWin32 = false; - if (workingFolder) + if (IsAQSQuery) { - var storageFolder = workingFolder.Result - ?? throw new InvalidOperationException($"The search folder '{folder}' could not be opened."); - await SearchAsync(storageFolder, results, token); - hiddenOnlyFromWin32 = (results.Count != 0); + if (storageFolder is not null) + await SearchAsync(storageFolder, results, token); + + return; } - if (!IsAQSQuery) + if (storageFolder is SystemStorageFolder && !DriveHelpers.IsMtpPath(folder) && !await IsFullyIndexedAsync(storageFolder)) { - await SearchWithWin32Async(folder, hiddenOnlyFromWin32, UsedMaxItemCount - (uint)results.Count, results, token); + await SearchWithWin32Async(folder, false, results, token); + return; } + + var hiddenOnlyFromWin32 = false; + if (storageFolder is not null) + { + var countBefore = results.Count; + await SearchAsync(storageFolder, results, token); + hiddenOnlyFromWin32 = results.Count != countBefore; + } + + await SearchWithWin32Async(folder, hiddenOnlyFromWin32, results, token); } } - private async Task SearchWithWin32Async(string folder, bool hiddenOnly, uint maxItemCount, IList results, CancellationToken token) + private static async Task IsFullyIndexedAsync(BaseStorageFolder folder) { - //var sampler = new IntervalSampler(500); - if (token.IsCancellationRequested) - return; - - (FindCloseSafeHandle? hFile, WIN32_FIND_DATAW findData) = await Task.Run(() => + try { - WIN32_FIND_DATAW findDataTsk = default; - FindCloseSafeHandle hFileTsk; - unsafe - { - hFileTsk = PInvoke.FindFirstFileEx($"{folder}\\*{QueryWithWildcard}", FINDEX_INFO_LEVELS.FindExInfoBasic, - &findDataTsk, FINDEX_SEARCH_OPS.FindExSearchNameMatch, FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); - } - return (hFileTsk, findDataTsk); - }).WithTimeoutAsync(TimeSpan.FromSeconds(5)); - if (token.IsCancellationRequested) + return await folder.GetIndexedStateAsync() == IndexedState.FullyIndexed; + } + catch (Exception) { - hFile?.Dispose(); - return; + return false; } + } - var pendingShortcuts = new List<(string Path, WIN32_FIND_DATAW FindData)>(); + private Task SearchWithWin32Async(string folder, bool hiddenOnly, IList results, CancellationToken token) + { + return Task.Factory.StartNew( + () => SearchWithWin32(folder, hiddenOnly, results, token), + token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default) + .WaitAsync(token); + } - if (hFile is { IsInvalid: false } findHandle) - { - // Always enter the delegate so the find handle is disposed; cancellation is checked before mutations. - await Task.Run(() => - { - using (findHandle) - { - var hasNextFile = false; - do - { - if (token.IsCancellationRequested) - break; + private void SearchWithWin32(string root, bool hiddenOnly, IList results, CancellationToken token) + { + var expression = FileSystemName.TranslateWin32Expression($"*{QueryWithWildcard}"); + var showHiddenItems = UserSettingsService.FoldersSettingsService.ShowHiddenItems; + var showProtectedSystemFiles = UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles; + var showDotFiles = UserSettingsService.FoldersSettingsService.ShowDotFiles; + + var pendingFolders = new Queue<(string Path, bool UseFindTimeout)>(); + pendingFolders.Enqueue((root, RequiresFindTimeout(root))); - if (results.Count >= maxItemCount) - break; + while (pendingFolders.TryDequeue(out var pendingFolder)) + { + var (folder, useFindTimeout) = pendingFolder; + if (token.IsCancellationRequested || results.Count >= UsedMaxItemCount) + return; - string fileName = findData.cFileName.ToString(); - var itemPath = Path.Combine(folder, fileName); - var isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) == FileAttributes.System; - var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; - var startWithDot = fileName.StartsWith('.'); - var isShortcut = FileExtensionHelpers.IsShortcutOrUrlFile(fileName); + using (var findHandle = FindFirstFile(folder, useFindTimeout, out var findData, token)) + { + if (findHandle is null || findHandle.IsInvalid) + continue; - bool shouldBeListed = (hiddenOnly ? - (!isHidden && isShortcut) || (isHidden && UserSettingsService.FoldersSettingsService.ShowHiddenItems && (!isSystem || UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles)) : - !isHidden || (UserSettingsService.FoldersSettingsService.ShowHiddenItems && (!isSystem || UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles))) && - (!startWithDot || UserSettingsService.FoldersSettingsService.ShowDotFiles); + do + { + var fileName = findData.cFileName.ToString(); + if (fileName is "." or "..") + continue; - if (shouldBeListed) - { - if (isShortcut) - { - pendingShortcuts.Add((itemPath, findData)); - } - else - { - var item = GetListedItemAsync(itemPath, findData); - if (item is not null && !token.IsCancellationRequested) - results.Add(item); - } - } + var attributes = (FileAttributes)findData.dwFileAttributes; + var isDirectory = attributes.HasFlag(FileAttributes.Directory); + var reparseTag = attributes.HasFlag(FileAttributes.ReparsePoint) ? findData.dwReserved0 : 0; - if (!token.IsCancellationRequested && (results.Count == 32 || results.Count % 300 == 0 /*|| sampler.CheckNow()*/)) - SearchTick?.Invoke(this, EventArgs.Empty); - hasNextFile = PInvoke.FindNextFile(findHandle, out findData); - } while (hasNextFile); - } - }); - } - else - { - hFile?.Dispose(); - } + if (isDirectory && !IsNameSurrogateReparseTag(reparseTag)) + pendingFolders.Enqueue((Path.Combine(folder, fileName), useFindTimeout || IsCloudFilesReparseTag(reparseTag))); - foreach (var (itemPath, itemFindData) in pendingShortcuts) - { - if (results.Count >= maxItemCount || token.IsCancellationRequested) - break; + if (!FileSystemName.MatchesWin32Expression(expression, fileName)) + continue; - string shortcutFileName = itemFindData.cFileName.ToString(); - var isUrl = FileExtensionHelpers.IsWebLinkFile(shortcutFileName); - var shortcutFindData = itemFindData; - var isHidden = ((FileAttributes)shortcutFindData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; - PInvoke.FileTimeToSystemTime(shortcutFindData.ftLastWriteTime, out SYSTEMTIME modifiedTime); - PInvoke.FileTimeToSystemTime(shortcutFindData.ftCreationTime, out SYSTEMTIME createdTime); - var fileSize = Win32FindDataExtensions.GetSize(shortcutFindData); - var itemFileExtension = shortcutFileName.Contains('.', StringComparison.Ordinal) ? Path.GetExtension(itemPath)! : string.Empty; + var isSystem = attributes.HasFlag(FileAttributes.System); + var isHidden = attributes.HasFlag(FileAttributes.Hidden); + var startWithDot = fileName.StartsWith('.'); + var isShortcut = !isDirectory && FileExtensionHelpers.IsShortcutOrUrlFile(fileName); - var shortcutItem = new ShortcutItem(null) - { - PrimaryItemAttribute = StorageItemTypes.File, - FileExtension = itemFileExtension, - IsHiddenItem = isHidden, - Opacity = isHidden ? Constants.UI.DimItemOpacity : 1, - FileImage = null, - LoadFileIcon = false, - ItemNameRaw = shortcutFileName, - ItemDateModifiedReal = modifiedTime.ToDateTime(), - ItemDateCreatedReal = createdTime.ToDateTime(), - ItemType = isUrl ? Strings.ShortcutWebLinkFileType.GetLocalizedResource() : Strings.Shortcut.GetLocalizedResource(), - ItemPath = itemPath, - FileSize = fileSize.ToSizeString(), - FileSizeBytes = fileSize, - IsUrl = isUrl, - }; + bool shouldBeListed = (hiddenOnly ? + (!isHidden && isShortcut) || (isHidden && showHiddenItems && (!isSystem || showProtectedSystemFiles)) : + !isHidden || (showHiddenItems && (!isSystem || showProtectedSystemFiles))) && + (!startWithDot || showDotFiles); - if (results.Any(r => string.Equals(r.ItemPath, itemPath, StringComparison.OrdinalIgnoreCase))) - continue; + var itemPath = Path.Combine(folder, fileName); + if (!shouldBeListed || (hiddenOnly && indexedResultPaths.Contains(itemPath))) + continue; - if (MaxItemCount == 0) - { - _ = FileOperationsHelpers.ParseLinkAsync(itemPath).ContinueWith((t) => - { - if (t.IsCompletedSuccessfully && t.Result is not null) + try { - _ = FilesystemTasks.Wrap(() => MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => - { - shortcutItem.TargetPath = t.Result.TargetPath; - shortcutItem.Arguments = t.Result.Arguments; - shortcutItem.WorkingDirectory = t.Result.WorkingDirectory; - shortcutItem.RunAsAdmin = t.Result.RunAsAdmin; - shortcutItem.ShowWindowCommand = t.Result.ShowWindowCommand; - shortcutItem.PrimaryItemAttribute = t.Result.IsFolder ? StorageItemTypes.Folder : StorageItemTypes.File; - })); + if ((isShortcut ? GetShortcutItem(itemPath, findData) : GetListedItemAsync(itemPath, findData)) is { } item) + AddResult(results, item, token); } - }); - } - else - { - var iconResult = await FileThumbnailHelper.GetIconAsync( - itemPath, - Constants.ShellIconSizes.Small, - false, - IconOptions.ReturnIconOnly); - if (iconResult is not null) - shortcutItem.FileImage = await iconResult.ToBitmapAsync(); + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Error creating ListedItem from Win32 find data"); + } + } + while (!token.IsCancellationRequested && results.Count < UsedMaxItemCount && PInvoke.FindNextFile(findHandle, out findData)); } - if (token.IsCancellationRequested) - break; - - results.Add(shortcutItem); - - if (!token.IsCancellationRequested && (results.Count == 32 || results.Count % 300 == 0)) - { - SearchTick?.Invoke(this, EventArgs.Empty); - } + RaiseSearchTickIfDue(token); } - if (token.IsCancellationRequested) - return; + } + + private static FindCloseSafeHandle? FindFirstFile(string folder, bool useTimeout, out WIN32_FIND_DATAW findData, CancellationToken token) + { + if (!useTimeout) + return FindFirstFile(folder, out findData); - (FindCloseSafeHandle? hSubDir, WIN32_FIND_DATAW subDirData) = await Task.Run(() => + var findTask = Task.Run(() => (Handle: FindFirstFile(folder, out var data), Data: data)); + try { - WIN32_FIND_DATAW subDirDataTsk = default; - FindCloseSafeHandle hSubDirTsk; - unsafe + if (findTask.Wait(TimeSpan.FromSeconds(5), token)) { - hSubDirTsk = PInvoke.FindFirstFileEx($"{folder}\\*", FINDEX_INFO_LEVELS.FindExInfoBasic, - &subDirDataTsk, FINDEX_SEARCH_OPS.FindExSearchNameMatch, FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); + (var handle, findData) = findTask.Result; + return handle; } - return (hSubDirTsk, subDirDataTsk); - }).WithTimeoutAsync(TimeSpan.FromSeconds(5)); - if (token.IsCancellationRequested) - { - hSubDir?.Dispose(); - return; } - - if (hSubDir is { IsInvalid: false } subDirectoryHandle) + catch (OperationCanceledException) { - var subDirectories = new List(); - - // Always enter the delegate so the find handle is disposed; cancellation is checked before mutations. - await Task.Run(() => - { - using (subDirectoryHandle) - { - var hasNextDir = false; - do - { - if (token.IsCancellationRequested) - break; + } - string subDirName = subDirData.cFileName.ToString(); - var isDirectory = ((FileAttributes)subDirData.dwFileAttributes & FileAttributes.Directory) == FileAttributes.Directory; - if (isDirectory && subDirName != "." && subDirName != "..") - subDirectories.Add(Path.Combine(folder, subDirName)); + _ = findTask.ContinueWith(t => t.Result.Handle.Dispose(), TaskContinuationOptions.OnlyOnRanToCompletion); + findData = default; + return null; + } - hasNextDir = PInvoke.FindNextFile(subDirectoryHandle, out subDirData); - } while (hasNextDir); - } - }); + private static unsafe FindCloseSafeHandle FindFirstFile(string folder, out WIN32_FIND_DATAW findData) + { + WIN32_FIND_DATAW data = default; + var handle = PInvoke.FindFirstFileEx(Path.Join(folder, "*"), FINDEX_INFO_LEVELS.FindExInfoBasic, + &data, FINDEX_SEARCH_OPS.FindExSearchNameMatch, FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); + findData = data; + return handle; + } - foreach (var subDir in subDirectories) - { - if (results.Count >= maxItemCount || token.IsCancellationRequested) - break; + private static bool RequiresFindTimeout(string root) + { + if (DriveHelpers.IsNetworkPath(root)) + return true; - await SearchWithWin32Async(subDir, hiddenOnly, maxItemCount - (uint)results.Count, results, token); - } + try + { + // Virtual cloud drives such as Google Drive report Fixed but aren't NTFS + var drive = new DriveInfo(root); + return drive.DriveType is not System.IO.DriveType.Fixed + || drive.DriveFormat is not ("NTFS" or "ReFS") + || File.GetAttributes(root).HasFlag(FileAttributes.ReparsePoint); } - else + catch (Exception) { - hSubDir?.Dispose(); + return true; } } + // Junctions, symlinks and mount points + private static bool IsNameSurrogateReparseTag(uint reparseTag) + => (reparseTag & 0x20000000) != 0; + + // IO_REPARSE_TAG_CLOUD and IO_REPARSE_TAG_CLOUD_1 to _F + private static bool IsCloudFilesReparseTag(uint reparseTag) + => (reparseTag & 0xFFFF0FFF) == 0x9000001A; + + private static ShortcutItem GetShortcutItem(string itemPath, WIN32_FIND_DATAW findData) + { + string fileName = findData.cFileName.ToString(); + var isUrl = FileExtensionHelpers.IsWebLinkFile(fileName); + var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; + PInvoke.FileTimeToSystemTime(findData.ftLastWriteTime, out SYSTEMTIME modifiedTime); + PInvoke.FileTimeToSystemTime(findData.ftCreationTime, out SYSTEMTIME createdTime); + var fileSize = Win32FindDataExtensions.GetSize(findData); + + return new ShortcutItem(null) + { + PrimaryItemAttribute = StorageItemTypes.File, + FileExtension = fileName.Contains('.', StringComparison.Ordinal) ? Path.GetExtension(itemPath)! : string.Empty, + IsHiddenItem = isHidden, + Opacity = isHidden ? Constants.UI.DimItemOpacity : 1, + FileImage = null, + LoadFileIcon = false, + ItemNameRaw = fileName, + ItemDateModifiedReal = modifiedTime.ToDateTime(), + ItemDateCreatedReal = createdTime.ToDateTime(), + ItemType = isUrl ? Strings.ShortcutWebLinkFileType.GetLocalizedResource() : Strings.Shortcut.GetLocalizedResource(), + ItemPath = itemPath, + FileSize = fileSize.ToSizeString(), + FileSizeBytes = fileSize, + IsUrl = isUrl, + }; + } + private ListedItem? GetListedItemAsync(string itemPath, WIN32_FIND_DATAW findData) { string fileName = findData.cFileName.ToString(); @@ -680,27 +757,6 @@ await Task.Run(() => } } - if (listedItem is not null && MaxItemCount > 0) // Only load icon for searchbox suggestions - { - _ = FileThumbnailHelper.GetIconAsync( - listedItem.ItemPath, - Constants.ShellIconSizes.Small, - isFolder, - IconOptions.ReturnIconOnly) - .ContinueWith((t) => - { - if (t.IsCompletedSuccessfully && t.Result is not null) - { - _ = FilesystemTasks.Wrap(() => MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => - { - var bitmapImage = await t.Result.ToBitmapAsync(); - if (bitmapImage is not null) - listedItem.FileImage = bitmapImage; - }, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low)); - } - }); - } - return listedItem; } @@ -799,24 +855,6 @@ private async Task GetListedItemAsync(IStorageItem item) FileSizeBytes = (long)props.Size, IsUrl = isUrl, }; - if (MaxItemCount == 0) - { - _ = FileOperationsHelpers.ParseLinkAsync(file.Path).ContinueWith((t) => - { - if (t.IsCompletedSuccessfully && t.Result is not null) - { - _ = FilesystemTasks.Wrap(() => MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => - { - shortcutItem.TargetPath = t.Result.TargetPath; - shortcutItem.Arguments = t.Result.Arguments; - shortcutItem.WorkingDirectory = t.Result.WorkingDirectory; - shortcutItem.RunAsAdmin = t.Result.RunAsAdmin; - shortcutItem.ShowWindowCommand = t.Result.ShowWindowCommand; - shortcutItem.PrimaryItemAttribute = t.Result.IsFolder ? StorageItemTypes.Folder : StorageItemTypes.File; - })); - } - }); - } listedItem = shortcutItem; } else @@ -837,17 +875,6 @@ private async Task GetListedItemAsync(IStorageItem item) }; } } - if (listedItem is not null && MaxItemCount > 0) // Only load icon for searchbox suggestions - { - var iconResult = await FileThumbnailHelper.GetIconAsync( - item.Path, - Constants.ShellIconSizes.Small, - item.IsOfType(StorageItemTypes.Folder), - IconOptions.ReturnIconOnly); - - if (iconResult is not null) - listedItem.FileImage = await iconResult.ToBitmapAsync(); - } return listedItem ?? throw new InvalidOperationException($"The search result '{item.Path}' is neither a file nor a folder."); } @@ -866,7 +893,6 @@ private QueryOptions ToQueryOptions() query.SortOrder.Add(new SortEntry { PropertyName = "System.Search.Rank", AscendingOrder = false }); query.SetPropertyPrefetch(PropertyPrefetchOptions.BasicProperties, null); - query.SetThumbnailPrefetch(ThumbnailMode.ListView, 24, ThumbnailOptions.UseCurrentScale); return query; } diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index aa6b8edfd695..d3a44093007f 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -32,6 +32,8 @@ namespace Files.App.ViewModels /// public sealed partial class ShellViewModel : ObservableObject, IDisposable { + private const int MaxSearchResultsAppendedPerTick = 500; + private readonly SemaphoreSlim enumFolderSemaphore; private readonly SemaphoreSlim getFileOrFolderSemaphore; private readonly SemaphoreSlim bulkOperationSemaphore; @@ -52,6 +54,10 @@ public sealed partial class ShellViewModel : ObservableObject, IDisposable // Files and folders list for manipulating private ConcurrentCollection filesAndFolders; + + // Search results waiting to be displayed, only accessed on the UI thread + private readonly Queue pendingSearchResults = new(); + private readonly IWindowsIniService WindowsIniService = Ioc.Default.GetRequiredService(); private readonly IWindowsJumpListService jumpListService = Ioc.Default.GetRequiredService(); private readonly IDialogService dialogService = Ioc.Default.GetRequiredService(); @@ -1102,6 +1108,9 @@ private void FilesAndFolderFilterUpdated() } + private static bool MatchesFilter(ListedItem item, string? filter) + => string.IsNullOrEmpty(filter) || item.Name?.Contains(filter, StringComparison.OrdinalIgnoreCase) == true; + // Apply changes immediately after manipulating on filesAndFolders completed public async Task ApplyFilesAndFoldersChangesAsync() { @@ -1125,8 +1134,7 @@ public async Task ApplyFilesAndFoldersChangesAsync() var displayedFilesAndFolders = string.IsNullOrEmpty(filter) ? filesAndFoldersLocal - : await Task.Run(() => filesAndFoldersLocal.Where( - x => x.Name?.Contains(filter, StringComparison.OrdinalIgnoreCase) == true).ToList(), addFilesCTS.Token); + : await Task.Run(() => filesAndFoldersLocal.Where(x => MatchesFilter(x, filter)).ToList(), addFilesCTS.Token); await dispatcherQueue.EnqueueOrInvokeAsync(() => { @@ -1135,6 +1143,8 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => if (addFilesCTS.IsCancellationRequested || FilesAndFoldersFilter != filter) return; + pendingSearchResults.Clear(); + FilesAndFolders.BeginBulkOperation(); try { @@ -3436,7 +3446,8 @@ public async Task SearchAsync(FolderSearch search) ItemLoadStatusChanged?.Invoke(this, new ItemLoadStatusChangedEventArgs() { Status = ItemLoadStatusChangedEventArgs.ItemLoadStatus.Starting }); CancelSearch(); - searchCTS = new CancellationTokenSource(); + var currentSearchCTS = searchCTS = new CancellationTokenSource(); + var token = currentSearchCTS.Token; filesAndFolders.Clear(); IsLoadingItems = true; IsSearchResults = true; @@ -3453,23 +3464,88 @@ public async Task SearchAsync(FolderSearch search) ItemLoadStatusChanged?.Invoke(this, new ItemLoadStatusChangedEventArgs() { Status = ItemLoadStatusChangedEventArgs.ItemLoadStatus.InProgress }); + // Stops late ticks from appending after the final apply + using var tickCTS = CancellationTokenSource.CreateLinkedTokenSource(token); + var tickToken = tickCTS.Token; + void Search_SearchTick(object? sender, IReadOnlyList newItems) => _ = AppendSearchResultsAsync(newItems, tickToken); + var results = new List(); - search.SearchTick += async (s, e) => + search.DispatcherQueue = dispatcherQueue; + search.SearchTick += Search_SearchTick; + await search.SearchAsync(results, token); + search.SearchTick -= Search_SearchTick; + tickCTS.Cancel(); + + if (!token.IsCancellationRequested) { + await PreloadIconsAsync(results); filesAndFolders = new ConcurrentCollection(results); + await OrderFilesAndFoldersAsync(); await ApplyFilesAndFoldersChangesAsync(); - }; + } - await search.SearchAsync(results, searchCTS.Token); + // A newer search owns the loading state + if (ReferenceEquals(searchCTS, currentSearchCTS)) + { + ItemLoadStatusChanged?.Invoke(this, new ItemLoadStatusChangedEventArgs() { Status = ItemLoadStatusChangedEventArgs.ItemLoadStatus.Complete }); + IsLoadingItems = false; + } + } - filesAndFolders = new ConcurrentCollection(results); + private async Task AppendSearchResultsAsync(IReadOnlyList newItems, CancellationToken token) + { + try + { + await bulkOperationSemaphore.WaitAsync(token); + } + catch (OperationCanceledException) + { + return; + } - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); + try + { + await PreloadIconsAsync(newItems); + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + if (token.IsCancellationRequested) + return; - ItemLoadStatusChanged?.Invoke(this, new ItemLoadStatusChangedEventArgs() { Status = ItemLoadStatusChangedEventArgs.ItemLoadStatus.Complete }); - IsLoadingItems = false; + filesAndFolders.AddRange(newItems); + foreach (var item in newItems) + pendingSearchResults.Enqueue(item); + + var filter = FilesAndFoldersFilter; + var appendedCount = 0; + while (appendedCount < MaxSearchResultsAppendedPerTick && pendingSearchResults.TryDequeue(out var item)) + { + if (!MatchesFilter(item, filter)) + continue; + + FilesAndFolders.Add(item); + appendedCount++; + } + + UpdateEmptyTextType(); + DirectoryInfoUpdated?.Invoke(this, EventArgs.Empty); + }); + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, ex.Message); + } + finally + { + bulkOperationSemaphore.Release(); + } + } + + private async Task PreloadIconsAsync(IEnumerable items) + { + var iconSize = GetPreloadIconSize(); + foreach (var item in items) + item.PreloadedIconData ??= await iconCacheService.GetIconAsync(item.ItemPath, item.IsFolder ? null : item.FileExtension, item.IsFolder, iconSize); } public void CancelSearch() From 923879312ba519687850fecfb9ba617047404973 Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 6 Oct 2026 00:09:11 +0900 Subject: [PATCH 2/5] Code Quality: Improved startup performance (#19002) --- src/Files.App/App.xaml.cs | 86 +++++++++---------- .../Data/Items/WidgetFolderCardItem.cs | 30 +++++-- src/Files.App/Extensions/StringExtensions.cs | 40 ++++++--- .../Helpers/Application/AppLanguageHelper.cs | 3 + .../Helpers/Application/AppLifecycleHelper.cs | 8 +- src/Files.App/Helpers/UI/AppSystemBackdrop.cs | 6 ++ src/Files.App/Helpers/UI/UIHelpers.cs | 32 +++++++ src/Files.App/MainWindow.xaml.cs | 67 +++++++++++++-- .../Services/App/AppThemeModeService.cs | 2 +- .../Settings/GeneralSettingsService.cs | 12 ++- .../UserControls/NavigationToolbar.xaml | 7 +- .../UserControls/NavigationToolbar.xaml.cs | 3 + .../UserControls/TabBar/TabBar.xaml.cs | 8 +- .../CachingJsonSettingsDatabase.cs | 4 +- .../Widgets/QuickAccessWidgetViewModel.cs | 49 ++++++++--- src/Files.App/Views/MainPage.xaml.cs | 13 ++- src/Files.App/Views/ShellPanesPage.xaml.cs | 1 + 17 files changed, 275 insertions(+), 96 deletions(-) diff --git a/src/Files.App/App.xaml.cs b/src/Files.App/App.xaml.cs index 0e4c9657dd82..a9113d1741e2 100644 --- a/src/Files.App/App.xaml.cs +++ b/src/Files.App/App.xaml.cs @@ -83,6 +83,7 @@ async Task ActivateAsync() { // Build the DI container off-thread while the window initializes var appModel = AppModel; + Task commandsWarmupTask = Task.CompletedTask; var servicesTask = Task.Run(() => { try @@ -92,31 +93,36 @@ async Task ActivateAsync() // Configure Ioc here so Ioc.Default-dependent constructions warm off-thread too Ioc.Default.ConfigureServices(provider); - // Warm the settings file reads off the UI thread + // Load the appearance settings needed for the first frame off the UI thread. _ = provider.GetRequiredService().LeaveAppRunning; - _ = provider.GetRequiredService().AppThemeBackdropMaterial; - - // Read through these statics by the action/context ctors warmed below - QuickAccessManager = provider.GetRequiredService(); - HistoryWrapper = provider.GetRequiredService(); - FileTagsManager = provider.GetRequiredService(); - LibraryManager = provider.GetRequiredService(); - - // Warm every command and hotkey off-thread, below normal so window creation wins the cores - var previousPriority = Thread.CurrentThread.Priority; - Thread.CurrentThread.Priority = ThreadPriority.BelowNormal; - try - { - _ = provider.GetRequiredService(); - } - catch (Exception) - { - // A command ctor that needs the UI thread aborts the warm-up; it runs on first use instead - } - finally + var appearanceSettings = provider.GetRequiredService(); + _ = appearanceSettings.AppThemeMode; + _ = appearanceSettings.AppThemeBackdropMaterial; + + // Commands are needed for the main page, not for the initial window and backdrop. + commandsWarmupTask = Task.Run(() => { - Thread.CurrentThread.Priority = previousPriority; - } + var previousPriority = Thread.CurrentThread.Priority; + Thread.CurrentThread.Priority = ThreadPriority.BelowNormal; + try + { + // Action and context constructors access these statics. + QuickAccessManager = provider.GetRequiredService(); + HistoryWrapper = provider.GetRequiredService(); + FileTagsManager = provider.GetRequiredService(); + LibraryManager = provider.GetRequiredService(); + + _ = provider.GetRequiredService(); + } + catch (Exception) + { + // UI-thread-only constructors run on first use instead. + } + finally + { + Thread.CurrentThread.Priority = previousPriority; + } + }); return provider; } @@ -136,17 +142,8 @@ async Task ActivateAsync() if (!isStartupTask) { - // Initialize and activate MainWindow - MainWindow.Instance.Activate(); - - if (showSplashScreen) - { - // Wait for the Window to initialize - await Task.Delay(10); - - SplashScreenLoadingTCS = new TaskCompletionSource(); - MainWindow.Instance.ShowSplashScreen(); - } + // Construct the window while appearance settings are loaded. + _ = MainWindow.Instance; } // Configure the DI (dependency injection) container @@ -157,28 +154,27 @@ async Task ActivateAsync() Ioc.Default.ConfigureServices(serviceProvider); } - // Configure Sentry - if (AppLifecycleHelper.AppEnvironment is not AppEnvironment.Dev) - AppLifecycleHelper.ConfigureSentry(); - var userSettingsService = Ioc.Default.GetRequiredService(); var isLeaveAppRunning = userSettingsService.GeneralSettingsService.LeaveAppRunning; - if (isStartupTask && !isLeaveAppRunning) + if (!isStartupTask || !isLeaveAppRunning) { - // Initialize and activate MainWindow - MainWindow.Instance.Activate(); - if (showSplashScreen) { - // Wait for the Window to initialize - await Task.Delay(10); - SplashScreenLoadingTCS = new TaskCompletionSource(); MainWindow.Instance.ShowSplashScreen(); } + + if (!await MainWindow.Instance.ActivateWithBackdropAsync()) + return; } + await commandsWarmupTask; + + // Configure Sentry + if (AppLifecycleHelper.AppEnvironment is not AppEnvironment.Dev) + AppLifecycleHelper.ConfigureSentry(); + // TODO: Replace with DI QuickAccessManager = Ioc.Default.GetRequiredService(); HistoryWrapper = Ioc.Default.GetRequiredService(); diff --git a/src/Files.App/Data/Items/WidgetFolderCardItem.cs b/src/Files.App/Data/Items/WidgetFolderCardItem.cs index c3ea40506d40..3b56f4eae2ce 100644 --- a/src/Files.App/Data/Items/WidgetFolderCardItem.cs +++ b/src/Files.App/Data/Items/WidgetFolderCardItem.cs @@ -3,6 +3,8 @@ using Microsoft.UI.Xaml.Media.Imaging; using Windows.Win32; +using Windows.Win32.System.Com; +using Windows.Win32.System.WinRT; using Windows.Win32.UI.Shell; namespace Files.App.Data.Items @@ -23,16 +25,17 @@ public sealed partial class WidgetFolderCardItem : WidgetCardItem, IWidgetCardIt private BitmapImage? _Thumbnail; public BitmapImage? Thumbnail { get => _Thumbnail; set => SetProperty(ref _Thumbnail, value); } + private bool _isDisposed; // Constructor - public WidgetFolderCardItem(IWindowsStorable item, string text, bool isPinned, string tooltip) + public WidgetFolderCardItem(IWindowsStorable item, string text, string path, bool isPinned, string tooltip) { AutomationProperties = text; Item = item; Text = text; IsPinned = isPinned; - Path = item.GetDisplayName(SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); + Path = path; Tooltip = tooltip; } @@ -40,21 +43,36 @@ public WidgetFolderCardItem(IWindowsStorable item, string text, bool isPinned, s public async Task LoadCardThumbnailAsync() { - if (string.IsNullOrEmpty(Path)) + if (_isDisposed || string.IsNullOrEmpty(Path)) return; var thumbnailSize = (int)(Constants.ShellIconSizes.Large * App.AppModel.AppWindowDPI); // Ensure thumbnail size is at least 1 to prevent layout errors thumbnailSize = Math.Max(1, thumbnailSize); - Item.TryGetThumbnail(thumbnailSize, SIIGBF.SIIGBF_ICONONLY, out var rawThumbnailData); - if (rawThumbnailData is null) + var hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, Item.ThisPtr, out IAgileReference shellItemReference); + if (hr.ThrowIfFailedOnDebug().Failed) return; - Thumbnail = await rawThumbnailData.ToBitmapAsync(); + var rawThumbnailData = await STATask.RunPooled(() => + { + if (shellItemReference.Resolve(out IShellItem shellItem).ThrowIfFailedOnDebug().Failed) + return null; + + using var folder = new WindowsFolder(shellItem); + folder.TryGetThumbnail(thumbnailSize, SIIGBF.SIIGBF_ICONONLY, out var data); + return data; + }, App.Logger); + if (_isDisposed || rawThumbnailData is null) + return; + + var thumbnail = await rawThumbnailData.ToBitmapAsync(); + if (!_isDisposed) + Thumbnail = thumbnail; } public void Dispose() { + _isDisposed = true; Item.Dispose(); } } diff --git a/src/Files.App/Extensions/StringExtensions.cs b/src/Files.App/Extensions/StringExtensions.cs index c1409a2446f5..09525c655076 100644 --- a/src/Files.App/Extensions/StringExtensions.cs +++ b/src/Files.App/Extensions/StringExtensions.cs @@ -55,26 +55,29 @@ public static string WithEnding(this string str, string ending) return result; } - private static readonly ResourceMap resourcesTree = new ResourceManager().MainResourceMap.TryGetSubtree("Resources"); + private static readonly ResourceManager resourceManager = new(); + private static readonly ResourceMap resourcesTree = resourceManager.MainResourceMap.TryGetSubtree("Resources"); + private static readonly Lock resourceLookupLock = new(); + private static ResourceContext? resourceContext; private static readonly ConcurrentDictionary cachedResources = new(); private static readonly Dictionary abbreviations = new() { - { ByteSize.KiloByteSymbol, Strings.KiloByteSymbol.GetLocalizedResource() }, - { ByteSize.MegaByteSymbol, Strings.MegaByteSymbol.GetLocalizedResource() }, - { ByteSize.GigaByteSymbol, Strings.GigaByteSymbol.GetLocalizedResource() }, - { ByteSize.TeraByteSymbol, Strings.TeraByteSymbol.GetLocalizedResource() }, - { ByteSize.PetaByteSymbol, Strings.PetaByteSymbol.GetLocalizedResource() }, - { ByteSize.BitSymbol, Strings.ByteSymbol.GetLocalizedResource() }, - { ByteSize.ByteSymbol, Strings.ByteSymbol.GetLocalizedResource() } + { ByteSize.KiloByteSymbol, Strings.KiloByteSymbol }, + { ByteSize.MegaByteSymbol, Strings.MegaByteSymbol }, + { ByteSize.GigaByteSymbol, Strings.GigaByteSymbol }, + { ByteSize.TeraByteSymbol, Strings.TeraByteSymbol }, + { ByteSize.PetaByteSymbol, Strings.PetaByteSymbol }, + { ByteSize.BitSymbol, Strings.ByteSymbol }, + { ByteSize.ByteSymbol, Strings.ByteSymbol } }; public static string ConvertSizeAbbreviation(this string value) { foreach (var item in abbreviations) { - value = value.Replace(item.Key, item.Value, StringComparison.Ordinal); + value = value.Replace(item.Key, item.Value.GetLocalizedResource(), StringComparison.Ordinal); } return value; @@ -102,9 +105,24 @@ public static string GetLocalizedResource(this string resourceKey) return value; } - value = resourcesTree?.TryGetValue(resourceKey)?.ValueAsString; + lock (resourceLookupLock) + { + if (cachedResources.TryGetValue(resourceKey, out value)) + return value; + + resourceContext ??= resourceManager.CreateResourceContext(); + value = resourcesTree?.TryGetValue(resourceKey, resourceContext)?.ValueAsString; + return cachedResources[resourceKey] = value ?? string.Empty; + } + } - return cachedResources[resourceKey] = value ?? string.Empty; + internal static void ResetLocalizedResources() + { + lock (resourceLookupLock) + { + resourceContext = null; + cachedResources.Clear(); + } } } } diff --git a/src/Files.App/Helpers/Application/AppLanguageHelper.cs b/src/Files.App/Helpers/Application/AppLanguageHelper.cs index 673e69673c5d..fa7eb6ec5700 100644 --- a/src/Files.App/Helpers/Application/AppLanguageHelper.cs +++ b/src/Files.App/Helpers/Application/AppLanguageHelper.cs @@ -3,6 +3,7 @@ using System.Globalization; using Windows.Globalization; +using StringExtensions = Files.App.Extensions.StringExtensions; namespace Files.App.Helpers { @@ -87,6 +88,7 @@ public static bool TryChange(int index) // Update the primary language override ApplicationLanguages.PrimaryLanguageOverride = index == 0 ? _defaultCode : PreferredLanguage.Code; + StringExtensions.ResetLocalizedResources(); return true; } @@ -117,6 +119,7 @@ public static bool TryChange(string code) // Update the primary language override ApplicationLanguages.PrimaryLanguageOverride = index == 0 ? _defaultCode : PreferredLanguage.Code; + StringExtensions.ResetLocalizedResources(); return true; } } diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index c51d27d0267b..630158d15873 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -86,13 +86,15 @@ static AppLifecycleHelper() /// /// Gets application icon path. /// - public static string AppIconPath { get; } = - SystemIO.Path.Combine(Package.Current.InstalledLocation.Path, AppEnvironment switch + public static string AppIconPath => _appIconPath.Value; + + private static readonly Lazy _appIconPath = new(() => + SystemIO.Path.Combine(Package.Current.InstalledPath, AppEnvironment switch { AppEnvironment.Dev => Constants.AssetPaths.DevLogo, AppEnvironment.SideloadPreview or AppEnvironment.StorePreview => Constants.AssetPaths.PreviewLogo, _ => Constants.AssetPaths.StableLogo - }); + })); /// /// Initializes the app components. diff --git a/src/Files.App/Helpers/UI/AppSystemBackdrop.cs b/src/Files.App/Helpers/UI/AppSystemBackdrop.cs index 334ce02d2773..033c9c030386 100644 --- a/src/Files.App/Helpers/UI/AppSystemBackdrop.cs +++ b/src/Files.App/Helpers/UI/AppSystemBackdrop.cs @@ -16,6 +16,7 @@ internal sealed partial class AppSystemBackdrop : SystemBackdrop private XamlRoot? root; private SystemBackdropConfiguration? configuration; private SystemBackdropTheme? prevTheme = null; + private bool? isInputActive; public AppSystemBackdrop(bool isSecondaryWindow = false) { @@ -33,6 +34,8 @@ protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop con this.target = connectedTarget; this.root = xamlRoot; configuration = GetDefaultSystemBackdropConfiguration(connectedTarget, xamlRoot); + if (isInputActive is bool active) + configuration.IsInputActive = active; controller = GetSystemBackdropController(userSettingsService.AppearanceSettingsService.AppThemeBackdropMaterial, configuration.Theme); controller?.SetSystemBackdropConfiguration(configuration); controller?.AddSystemBackdropTarget(connectedTarget); @@ -82,6 +85,8 @@ private void OnSettingChanged(object? sender, SettingChangedEventArgs e) controller?.RemoveAllSystemBackdropTargets(); controller?.Dispose(); configuration = GetDefaultSystemBackdropConfiguration(target, root); + if (isInputActive is bool active) + configuration.IsInputActive = active; var newController = GetSystemBackdropController(userSettingsService.AppearanceSettingsService.AppThemeBackdropMaterial, configuration.Theme); newController?.SetSystemBackdropConfiguration(configuration); newController?.AddSystemBackdropTarget(target); @@ -93,6 +98,7 @@ private void OnSettingChanged(object? sender, SettingChangedEventArgs e) // Driven from the window's Activated event since the default configuration doesn't reliably flip IsInputActive on pointerless activation (e.g. taskbar). public void SetInputActive(bool isInputActive) { + this.isInputActive = isInputActive; if (configuration is not null) configuration.IsInputActive = isInputActive; } diff --git a/src/Files.App/Helpers/UI/UIHelpers.cs b/src/Files.App/Helpers/UI/UIHelpers.cs index 3d470d670e47..1b0a248dabe6 100644 --- a/src/Files.App/Helpers/UI/UIHelpers.cs +++ b/src/Files.App/Helpers/UI/UIHelpers.cs @@ -19,6 +19,38 @@ public static class UIHelpers { public static event PropertyChangedEventHandler? PropertyChanged; + public static void RunAfterNextRender(this FrameworkElement element, Action action) + { + if (!element.IsLoaded) + return; + + var canceled = false; + element.Unloaded += OnUnloaded; + CompositionTarget.Rendered += OnRendered; + + void OnUnloaded(object sender, RoutedEventArgs e) + { + canceled = true; + element.Unloaded -= OnUnloaded; + CompositionTarget.Rendered -= OnRendered; + } + + void OnRendered(object? sender, RenderedEventArgs e) + { + CompositionTarget.Rendered -= OnRendered; + // Keep the deferred work outside the rendering callback itself. + if (!element.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + element.Unloaded -= OnUnloaded; + if (!canceled && element.IsLoaded) + action(); + })) + { + element.Unloaded -= OnUnloaded; + } + } + } + /// /// True if a user-editable text input currently owns keyboard focus within the given XamlRoot. /// Used to gate code that programmatically reassigns focus on a background-completion diff --git a/src/Files.App/MainWindow.xaml.cs b/src/Files.App/MainWindow.xaml.cs index 3402f616f9bc..f369ddbe536c 100644 --- a/src/Files.App/MainWindow.xaml.cs +++ b/src/Files.App/MainWindow.xaml.cs @@ -4,7 +4,9 @@ using Microsoft.Extensions.Logging; using Microsoft.UI; using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Animation; using System.IO; using System.Runtime.InteropServices; @@ -24,6 +26,7 @@ public sealed partial class MainWindow : WindowEx private bool CanWindowToFront { get; set; } = true; private readonly Lock _canWindowToFrontLock = new(); + private bool _isWindowIconInitialized; protected override bool PersistPlacement => true; @@ -37,10 +40,49 @@ public MainWindow() : base(416, 316) AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent; AppWindow.TitleBar.ButtonPressedBackgroundColor = Colors.Transparent; AppWindow.TitleBar.ButtonHoverBackgroundColor = Colors.Transparent; + } + + private void InitializeWindowIcon() + { + if (_isWindowIconInitialized) + return; + + AppWindow.SetIcon(AppLifecycleHelper.AppIconPath); + _isWindowIconInitialized = true; + } + + internal async Task ActivateWithBackdropAsync() + { + var rootFrame = EnsureWindowIsInitialized(); + if (rootFrame is null) + return false; + + // Submit the lightweight backdrop before showing the window with its normal animation. + await CompositionTarget.GetCompositorForCurrentThread().RequestCommitAsync(); + + var firstFrame = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CompositionTarget.Rendered += OnRendered; + Closed += OnClosed; + try + { + Activate(); + // Let the backdrop paint before main-page construction occupies the UI thread. + return await firstFrame.Task; + } + finally + { + CompositionTarget.Rendered -= OnRendered; + Closed -= OnClosed; + } + + void OnRendered(object? sender, RenderedEventArgs args) + { + if (rootFrame.IsLoaded) + firstFrame.TrySetResult(true); + } - // Deferred: reads the .ico from disk - DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => - AppWindow.SetIcon(AppLifecycleHelper.AppIconPath)); + void OnClosed(object sender, WindowEventArgs args) + => firstFrame.TrySetResult(false); } public void ShowSplashScreen() @@ -58,16 +100,13 @@ public async Task InitializeApplicationAsync(object? activatedEventArgs) if (rootFrame is null) return; - // Reuse the existing backdrop on resume; rebuilding the Mica controller leaves the window on the fallback color for ~1s - SystemBackdrop ??= new AppSystemBackdrop(); - switch (activatedEventArgs) { case ILaunchActivatedEventArgs launchArgs: if (launchArgs.Arguments is not null && (CommandLineParser.SplitArguments(launchArgs.Arguments, true)[0].EndsWith($"files-dev.exe", StringComparison.OrdinalIgnoreCase) || CommandLineParser.SplitArguments(launchArgs.Arguments, true)[0].EndsWith($"files-dev", StringComparison.OrdinalIgnoreCase) - || CommandLineParser.SplitArguments(launchArgs.Arguments, true)[0].Equals(Path.Join(Package.Current.InstalledLocation.Path, "Files.exe"), StringComparison.OrdinalIgnoreCase))) + || CommandLineParser.SplitArguments(launchArgs.Arguments, true)[0].Equals(Path.Join(Package.Current.InstalledPath, "Files.exe"), StringComparison.OrdinalIgnoreCase))) { // WINUI3: When launching from commandline the argument is not ICommandLineActivatedEventArgs (#10370) var ppm = CommandLineParser.ParseUntrustedCommands(launchArgs.Arguments); @@ -250,6 +289,11 @@ private async Task EnsureContentHasKeyboardFocusAsync() { // Create a Frame to act as the navigation context and navigate to the first page rootFrame = new() { CacheSize = 1 }; + rootFrame.Loaded += (_, _) => + { + if (!_isWindowIconInitialized) + rootFrame.RunAfterNextRender(InitializeWindowIcon); + }; rootFrame.NavigationFailed += (s, e) => { throw new Exception("Failed to load Page " + e.SourcePageType.FullName); @@ -257,6 +301,15 @@ private async Task EnsureContentHasKeyboardFocusAsync() // Place the frame in the current Window Instance.Content = rootFrame; + Ioc.Default.GetRequiredService().SetAppThemeMode(this, callThemeModeChangedEvent: false); + } + + if (SystemBackdrop is null) + { + // Initialize the active material directly instead of transitioning from its inactive fallback. + var backdrop = new AppSystemBackdrop(); + backdrop.SetInputActive(true); + SystemBackdrop = backdrop; } return rootFrame; diff --git a/src/Files.App/Services/App/AppThemeModeService.cs b/src/Files.App/Services/App/AppThemeModeService.cs index d79825e47746..dbfa00ba2bd0 100644 --- a/src/Files.App/Services/App/AppThemeModeService.cs +++ b/src/Files.App/Services/App/AppThemeModeService.cs @@ -83,7 +83,7 @@ public void SetAppThemeMode(Window? window = null, AppWindowTitleBar? titleBar = try { window ??= MainWindow.Instance; - titleBar ??= MainWindow.Instance.AppWindow?.TitleBar; + titleBar ??= window.AppWindow?.TitleBar; rootTheme ??= AppThemeMode; if (window.Content is FrameworkElement rootElement) diff --git a/src/Files.App/Services/Settings/GeneralSettingsService.cs b/src/Files.App/Services/Settings/GeneralSettingsService.cs index e754b3c7705e..7801fd3143b8 100644 --- a/src/Files.App/Services/Settings/GeneralSettingsService.cs +++ b/src/Files.App/Services/Settings/GeneralSettingsService.cs @@ -401,7 +401,17 @@ public Dictionary? ShowHashesDictionary public string UserId { - get => Get(Guid.NewGuid().ToString()); + get + { + var userId = Get(null); + if (userId is null) + { + userId = Guid.NewGuid().ToString(); + Set(userId); + } + + return userId; + } set => Set(value); } diff --git a/src/Files.App/UserControls/NavigationToolbar.xaml b/src/Files.App/UserControls/NavigationToolbar.xaml index c3cab3a58c54..667099c68de3 100644 --- a/src/Files.App/UserControls/NavigationToolbar.xaml +++ b/src/Files.App/UserControls/NavigationToolbar.xaml @@ -21,6 +21,7 @@ xmlns:ucs="using:Files.App.UserControls.StatusCenter" xmlns:vm="using:Files.App.ViewModels.UserControls" x:Name="NavToolbar" + Loaded="NavToolbar_Loaded" Loading="NavToolbar_Loading" mc:Ignorable="d"> @@ -73,7 +74,11 @@ - + diff --git a/src/Files.App/UserControls/NavigationToolbar.xaml.cs b/src/Files.App/UserControls/NavigationToolbar.xaml.cs index c47b9484809c..3e3769d5866b 100644 --- a/src/Files.App/UserControls/NavigationToolbar.xaml.cs +++ b/src/Files.App/UserControls/NavigationToolbar.xaml.cs @@ -51,6 +51,9 @@ public NavigationToolbar() // Methods + private void NavToolbar_Loaded(object sender, RoutedEventArgs e) + => this.RunAfterNextRender(() => FindName(nameof(ListaryIntegration))); + private void NavToolbar_Loading(FrameworkElement _, object e) { Loading -= NavToolbar_Loading; diff --git a/src/Files.App/UserControls/TabBar/TabBar.xaml.cs b/src/Files.App/UserControls/TabBar/TabBar.xaml.cs index 58e6e793ae22..dd67e005e3c8 100644 --- a/src/Files.App/UserControls/TabBar/TabBar.xaml.cs +++ b/src/Files.App/UserControls/TabBar/TabBar.xaml.cs @@ -426,11 +426,11 @@ private void TabViewItem_Loaded(object sender, RoutedEventArgs e) } } - private async void DragAreaRectangle_Loaded(object sender, RoutedEventArgs e) - { - if (HorizontalTabView.ActualWidth <= 0 && TabBarAddNewTabButton.Width <= 0) - await Task.Delay(100); + private void DragAreaRectangle_Loaded(object sender, RoutedEventArgs e) + => this.RunAfterNextRender(UpdateTitleBarInsets); + private void UpdateTitleBarInsets() + { var titleBarInset = ((AppLanguageHelper.IsPreferredLanguageRtl ? MainWindow.Instance.AppWindow.TitleBar.LeftInset : MainWindow.Instance.AppWindow.TitleBar.RightInset) / DragAreaRectangle.XamlRoot.RasterizationScale) + 40; diff --git a/src/Files.App/Utils/Serialization/Implementation/CachingJsonSettingsDatabase.cs b/src/Files.App/Utils/Serialization/Implementation/CachingJsonSettingsDatabase.cs index d7df6480c46c..e6f6b268fff7 100644 --- a/src/Files.App/Utils/Serialization/Implementation/CachingJsonSettingsDatabase.cs +++ b/src/Files.App/Utils/Serialization/Implementation/CachingJsonSettingsDatabase.cs @@ -28,8 +28,8 @@ public CachingJsonSettingsDatabase( else { var defaultElement = GetElementFromValue(defaultValue); - if (_settingsCache.TryAdd(key, defaultElement) && !SaveSettings(_settingsCache)) - _settingsCache.TryRemove(key, out _); + // Persist cached defaults with the next settings change, not during reads. + _settingsCache.TryAdd(key, defaultElement); return defaultValue; } diff --git a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs index 8f365c3b4938..d0f75b8aa275 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs @@ -36,6 +36,9 @@ public sealed partial class QuickAccessWidgetViewModel : BaseWidgetViewModel, IW // TODO: Replace with IMutableFolder.GetWatcherAsync() once it gets implemented in IWindowsStorable private readonly SystemIO.FileSystemWatcher? _quickAccessFolderWatcher; private bool isDisposed; + private int _refreshVersion; + + private sealed record FolderSnapshot(IAgileReference ShellItem, string Text, string Path, bool IsPinned, string Tooltip); // Constructor @@ -75,23 +78,47 @@ public Task RefreshWidgetAsync() { return MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => { + if (isDisposed) + return; + + var refreshVersion = ++_refreshVersion; + var homeFolder = HomePageContext.HomeFolder; + var folders = await STATask.RunPooled(() => + { + List result = []; + // The Shell enumerator and its metadata reads stay on this persistent STA. + foreach (IWindowsStorable folder in homeFolder.GetQuickAccessFolderAsync().ToBlockingEnumerable()) + { + using (folder) + { + folder.GetPropertyValue("System.Home.IsPinned", out var isPinned); + folder.TryGetShellTooltip(out var tooltip); + var text = folder.GetDisplayName(SIGDN.SIGDN_PARENTRELATIVEFORUI); + var path = folder.GetDisplayName(SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); + var hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, folder.ThisPtr, out IAgileReference shellItem); + if (hr.ThrowIfFailedOnDebug().Failed) + continue; + + result.Add(new(shellItem, text, path, isPinned, tooltip ?? string.Empty)); + } + } + return result; + }, App.Logger); + + if (isDisposed || refreshVersion != _refreshVersion || folders is null) + return; + foreach (var item in Items) item.Dispose(); Items.Clear(); - await foreach (IWindowsStorable folder in HomePageContext.HomeFolder.GetQuickAccessFolderAsync(default)) + foreach (var folder in folders) { - folder.GetPropertyValue("System.Home.IsPinned", out var isPinned); - var tooltip = await STATask.RunPooled(() => folder.TryGetShellTooltip(out var t).Succeeded ? t : null, App.Logger); - - Items.Insert( - Items.Count, - new WidgetFolderCardItem( - folder, - folder.GetDisplayName(SIGDN.SIGDN_PARENTRELATIVEFORUI), - isPinned, - tooltip ?? string.Empty)); + if (folder.ShellItem.Resolve(out IShellItem shellItem).ThrowIfFailedOnDebug().Failed) + continue; + + Items.Add(new WidgetFolderCardItem(new WindowsFolder(shellItem), folder.Text, folder.Path, folder.IsPinned, folder.Tooltip)); } }); } diff --git a/src/Files.App/Views/MainPage.xaml.cs b/src/Files.App/Views/MainPage.xaml.cs index c480f651b73f..3577f29d915b 100644 --- a/src/Files.App/Views/MainPage.xaml.cs +++ b/src/Files.App/Views/MainPage.xaml.cs @@ -70,6 +70,11 @@ public MainPage() App.AppModel.PropertyChanged += AppModel_PropertyChanged; ApplySidebarWidthState(); + + // Realize the navigation controls before the first measure pass. + FindName(nameof(InnerNavigationToolbar)); + FindName(nameof(TabControl)); + FindName(nameof(NavToolbar)); } private void NumberedTabKeyboardAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs e) @@ -131,6 +136,9 @@ private void HorizontalMultitaskingControl_Loaded(object sender, RoutedEventArgs private int SetTitleBarDragRegion(InputNonClientPointerSource source, SizeInt32 size, double scaleFactor, Func getScaledRect) { + if (TabControl is null) + return -1; + var height = (int)TabControl.ActualHeight; source.SetRegionRects(NonClientRegionKind.Passthrough, [getScaledRect(this, new RectInt32(0, 0, (int)(TabControl.ActualWidth + TabControl.Margin.Left - TabControl.DragArea.ActualWidth), height))]); AttachTitleBarMessageMonitor(); @@ -328,10 +336,7 @@ private void Page_Loaded(object sender, RoutedEventArgs e) MainWindow.Instance.AppWindow.Changed += (_, _) => MainWindow.Instance.RaiseSetTitleBarDragRegion(SetTitleBarDragRegion); - // Defers loading until after the page has loaded to improve startup perf - FindName(nameof(InnerNavigationToolbar)); - FindName(nameof(TabControl)); - FindName(nameof(NavToolbar)); + MainWindow.Instance.RaiseSetTitleBarDragRegion(SetTitleBarDragRegion); // Notify user that drag and drop is disabled // Prompt is disabled in the dev environment to prevent issues with the automation testing diff --git a/src/Files.App/Views/ShellPanesPage.xaml.cs b/src/Files.App/Views/ShellPanesPage.xaml.cs index 7b6c35798fb6..0900abda6c73 100644 --- a/src/Files.App/Views/ShellPanesPage.xaml.cs +++ b/src/Files.App/Views/ShellPanesPage.xaml.cs @@ -962,6 +962,7 @@ private void Pane_ContentChanged(object? sender, TabBarItemParameter e) [DynamicWindowsRuntimeCast(typeof(UIElement))] [DynamicWindowsRuntimeCast(typeof(ButtonBase))] + [DynamicWindowsRuntimeCast(typeof(DependencyObject))] private void Pane_PointerPressed(object sender, PointerRoutedEventArgs e) { // A button cancels its press once it loses focus, so leave focus alone while one is being pressed From 6593fe4654ee03bc068dcd64225eb72614d63dea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:29:51 -0400 Subject: [PATCH 3/5] Bump Microsoft.Windows.CsWin32 from 0.3.335 to 0.3.346 (#19005) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 53de05b339d5..add5c0e9ef15 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,7 +42,7 @@ - + From d691c8c84cbae914388fd0549b98931e77d21321 Mon Sep 17 00:00:00 2001 From: yair100 <39923744+yair100@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:30:41 -0400 Subject: [PATCH 4/5] Build: v4.2.38 --- src/Files.App/Package.appxmanifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Files.App/Package.appxmanifest b/src/Files.App/Package.appxmanifest index 276e73dbb0f4..4dbde45b21a5 100644 --- a/src/Files.App/Package.appxmanifest +++ b/src/Files.App/Package.appxmanifest @@ -17,7 +17,7 @@ + Version="4.2.38.0" /> Files - Dev From 574d9ae15512e3e5e8290e996795a71c8c938a80 Mon Sep 17 00:00:00 2001 From: yair100 <39923744+yair100@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:53:40 -0400 Subject: [PATCH 5/5] Fix: Fixed several crashes and log noise reported by Sentry (#18985) --- .github/workflows/ci.yml | 11 +++++++++ .../Actions/Navigation/NextTabAction.cs | 6 ++--- .../Actions/Navigation/PreviousTabAction.cs | 6 ++--- .../UserControls/TabBar/TabBar.xaml.cs | 5 ++-- .../Utils/Cloud/CloudDrivesManager.cs | 11 +++++++++ src/Files.App/ViewModels/ShellViewModel.cs | 4 +++- .../Tests/ArchiveTests.cs | 24 +++++++++++++++++-- 7 files changed, 56 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb16082775a..29ddd90234ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,6 +354,17 @@ jobs: with: name: '*' + # Uploaded after the cleanup step above so a failed run keeps its diagnostics + - if: failure() + name: Upload the test diagnostics to the Artifacts + uses: actions/upload-artifact@v7 + with: + name: 'Test Diagnostics (${{ env.CONFIGURATION }}, ${{ env.AUTOMATED_TESTS_ARCHITECTURE }})' + if-no-files-found: warn + path: | + ${{ env.AUTOMATED_TESTS_ASSEMBLY_DIR }}\appium.log + ${{ env.AUTOMATED_TESTS_ASSEMBLY_DIR }}\testResults.trx + # - name: Generate markdown from the tests result # shell: pwsh # run: | diff --git a/src/Files.App/Actions/Navigation/NextTabAction.cs b/src/Files.App/Actions/Navigation/NextTabAction.cs index 980bc29dc557..1442aaf4f5d8 100644 --- a/src/Files.App/Actions/Navigation/NextTabAction.cs +++ b/src/Files.App/Actions/Navigation/NextTabAction.cs @@ -41,9 +41,9 @@ public async Task ExecuteAsync(object? parameter = null) // Small delay for the UI to load await Task.Delay(500); - // Focus the content of the selected tab item (needed for keyboard navigation) - var paneHolder = contentPageContext.ShellPage.GetRequiredPaneHolder(); - paneHolder.FocusActivePane(); + // Focus the content of the selected tab item (needed for keyboard navigation). + // The tab can be gone by the time the delay elapses, and focusing it is best effort + contentPageContext.ShellPage?.PaneHolder?.FocusActivePane(); } private void MultitaskingContext_PropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/Files.App/Actions/Navigation/PreviousTabAction.cs b/src/Files.App/Actions/Navigation/PreviousTabAction.cs index 19ecd9f30ad6..07616182aaac 100644 --- a/src/Files.App/Actions/Navigation/PreviousTabAction.cs +++ b/src/Files.App/Actions/Navigation/PreviousTabAction.cs @@ -44,9 +44,9 @@ public async Task ExecuteAsync(object? parameter = null) // Small delay for the UI to load await Task.Delay(500); - // Focus the content of the selected tab item (needed for keyboard navigation) - var paneHolder = contentPageContext.ShellPage.GetRequiredPaneHolder(); - paneHolder.FocusActivePane(); + // Focus the content of the selected tab item (needed for keyboard navigation). + // The tab can be gone by the time the delay elapses, and focusing it is best effort + contentPageContext.ShellPage?.PaneHolder?.FocusActivePane(); } private void MultitaskingContext_PropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/Files.App/UserControls/TabBar/TabBar.xaml.cs b/src/Files.App/UserControls/TabBar/TabBar.xaml.cs index dd67e005e3c8..155b685a1093 100644 --- a/src/Files.App/UserControls/TabBar/TabBar.xaml.cs +++ b/src/Files.App/UserControls/TabBar/TabBar.xaml.cs @@ -313,8 +313,9 @@ private async void TabView_TabDroppedOutside(TabView sender, TabViewTabDroppedOu Items.Remove(tabItem); if (!await NavigationHelpers.OpenTabInNewWindowAsync(tabViewItemArgs.Serialize(), droppedPoint.X, droppedPoint.Y)) { - Items.Insert(indexOfTabViewItem, tabItem); - sender.SelectedIndex = selectedTabViewItemIndex; + // Other tabs can close while the new window opens, so the remembered positions may no longer exist + Items.Insert(Math.Clamp(indexOfTabViewItem, 0, Items.Count), tabItem); + sender.SelectedIndex = Math.Clamp(selectedTabViewItemIndex, 0, Items.Count - 1); } else // Dispose tab arguments diff --git a/src/Files.App/Utils/Cloud/CloudDrivesManager.cs b/src/Files.App/Utils/Cloud/CloudDrivesManager.cs index 5aaf12b1925f..e4a7d5bea72d 100644 --- a/src/Files.App/Utils/Cloud/CloudDrivesManager.cs +++ b/src/Files.App/Utils/Cloud/CloudDrivesManager.cs @@ -4,12 +4,18 @@ using Microsoft.Extensions.Logging; using System.Collections.Specialized; using System.IO; +using System.Runtime.InteropServices; using Windows.Storage; namespace Files.App.Utils.Cloud { public static class CloudDrivesManager { + // HRESULT_FROM_WIN32 of ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND and ERROR_INVALID_DRIVE + private const int FileNotFoundHResult = unchecked((int)0x80070002); + private const int PathNotFoundHResult = unchecked((int)0x80070003); + private const int InvalidDriveHResult = unchecked((int)0x8007000F); + private static readonly ILogger _logger = Ioc.Default.GetRequiredService>(); private static readonly ICloudDetector _detector = Ioc.Default.GetRequiredService(); public static EventHandler? DataChanged; @@ -79,6 +85,11 @@ private static async Task SetRootAndUpdatePropertiesAsync(DriveItem cloudProvide { _logger?.LogInformation(ex, "Failed to find the cloud folder"); } + catch (COMException ex) when (ex.HResult is FileNotFoundHResult or PathNotFoundHResult or InvalidDriveHResult) + { + // A cloud folder whose drive or path is gone surfaces from the shell as an HRESULT instead + _logger?.LogInformation(ex, "Failed to find the cloud folder"); + } catch (UnauthorizedAccessException ex) { _logger?.LogInformation(ex, " Failed to access the cloud folder"); diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index d3a44093007f..ffc13ccd9a85 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -1183,7 +1183,9 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => } catch (Exception ex) { - App.Logger.LogWarning(ex, ex.Message); + // A newer load cancelled this one or disposed its token source, which is not worth reporting + if (ex is not (OperationCanceledException or ObjectDisposedException)) + App.Logger.LogWarning(ex, ex.Message); } } diff --git a/tests/Files.InteractionTests/Tests/ArchiveTests.cs b/tests/Files.InteractionTests/Tests/ArchiveTests.cs index 2e2f8ed662a4..4a72e08d20b2 100644 --- a/tests/Files.InteractionTests/Tests/ArchiveTests.cs +++ b/tests/Files.InteractionTests/Tests/ArchiveTests.cs @@ -93,8 +93,7 @@ private static void CompressAndExtractRoundTrip(string sourceFolderName, string foreach (var (name, content) in files) { var extractedFilePath = Path.Combine(testFolderPath, name); - WaitForCondition(() => File.Exists(extractedFilePath), $"'{name}' to be extracted"); - Assert.AreEqual(content, File.ReadAllText(extractedFilePath), $"The extracted file '{name}' does not match the original content."); + Assert.AreEqual(content, ReadWhenAvailable(extractedFilePath), $"The extracted file '{name}' does not match the original content."); } // The extracted files also show up in the file area @@ -139,6 +138,27 @@ private static void InvokeItemContextMenuCommand(string itemName, string subMenu } } + /// + /// Reads a file the extractor may still be writing. The entry appears on disk before its + /// write handle is closed, so both the missing file and the sharing violation are retried. + /// + private static string ReadWhenAvailable(string path) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + + while (true) + { + try + { + return File.ReadAllText(path); + } + catch (IOException) when (DateTime.UtcNow < deadline) + { + Thread.Sleep(300); + } + } + } + /// /// Waits until the given disk-state condition holds; archive operations run in the /// background, so their completion is only observable through the file system.