From d7db90dd606305d2f6825b47549e7c2f14517689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 22:51:33 +0300 Subject: [PATCH 1/2] Isolate ShellViewModel Windows code and list archives on Linux (P4-E) Move the FindFirstFileEx/StorageFolder enumeration, ReadDirectoryChangesW watchers, alternate data streams, Recycle Bin shell items and BitLocker prompt verbatim into ShellViewModel.Windows.cs. Linux lists archive roots and folders through IStorableResolver/ArchiveFolder (read-only, no watcher) and can navigate into folders inside an archive. Ratchet the compat-off baseline to 2 keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- docs/linux-port/compat-off-baseline.txt | 1 - .../Navigation/NavigationHelpers.Linux.cs | 17 + .../ViewModels/ShellViewModel.Linux.cs | 133 +++ .../ViewModels/ShellViewModel.Windows.cs | 938 +++++++++++++++++ src/Files.App/ViewModels/ShellViewModel.cs | 943 +----------------- 5 files changed, 1111 insertions(+), 921 deletions(-) create mode 100644 src/Files.App/ViewModels/ShellViewModel.Windows.cs diff --git a/docs/linux-port/compat-off-baseline.txt b/docs/linux-port/compat-off-baseline.txt index 5e497d2e8a98..0dfc7e761abe 100644 --- a/docs/linux-port/compat-off-baseline.txt +++ b/docs/linux-port/compat-off-baseline.txt @@ -2,4 +2,3 @@ # One "file: error CODE: message" per line. Regenerate with scripts/linux/compat-off-check.sh --update. src/Files.App/Data/Items/ContextMenu.cs: error CS0246: The type or namespace name 'MENU_ITEM_TYPE' could not be found (are you missing a using directive or an assembly reference?) src/Files.App/Data/Items/WidgetRecentItem.cs: error CS0246: The type or namespace name 'IShellItem' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/ViewModels/ShellViewModel.cs: error CS0234: The type or namespace name 'Win32PInvoke' does not exist in the namespace 'Files.App.Helpers' (are you missing an assembly reference?) diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs index 0738c686bbbc..79372fb96256 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs @@ -7,6 +7,7 @@ using Files.Platform.Abstractions.Mime; using Files.Platform.Linux.Launching; using Files.Platform.Linux.Mime; +using Files.Shared.Helpers; using Microsoft.Extensions.Logging; using System.Globalization; using System.IO; @@ -33,6 +34,22 @@ private static async Task OpenPathLinuxAsync(string path, IShellPage assoc return true; } + // Members of an archive being browsed; the archive file itself still opens with its default application + if (FileExtensionHelpers.IsZipPath(path, includeRoot: false)) + { + var resolved = await Ioc.Default.GetRequiredService().TryGetAsync(path); + if (resolved.Item is OwlCore.Storage.IFolder) + { + var foldersSettings = Ioc.Default.GetRequiredService().FoldersSettingsService; + await OpenPath(forceOpenInNewTab, foldersSettings.OpenFoldersInNewTab, path, associatedInstance, selectItems); + return true; + } + + // LINUX-TODO(archives): open archive members by extracting them to a private temporary copy first + await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenFailedTitle.GetLocalizedResource(), Strings.LinuxOpenFailedText.GetLocalizedFormatResource(DisplaySanitizer.Field(path))); + return false; + } + if (!File.Exists(path)) { await DialogDisplayHelper.ShowDialogAsync(Strings.FileNotFoundDialogTitle.GetLocalizedResource(), Strings.FileNotFoundDialogText.GetLocalizedResource()); diff --git a/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs b/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs index a785bb1805b9..688552bbca31 100644 --- a/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs +++ b/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs @@ -1,13 +1,18 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using Files.App.Storage.Archives; +using Files.Core.Storage.Contracts; +using Files.Core.Storage.Enums; using Files.Platform.Abstractions.Enumeration; using Files.Platform.Abstractions.Trash; using Files.Platform.Abstractions.Watching; using Files.Platform.Linux.Enumeration; using Files.Platform.Linux.Mime; using Files.Platform.Linux.Watching; +using Files.Shared.Helpers; using Microsoft.Extensions.Logging; +using OwlCore.Storage; using System.Globalization; using System.IO; using Windows.Storage; @@ -30,6 +35,9 @@ public sealed partial class ShellViewModel private CancellationTokenSource? _linuxChangeCts; private int _linuxChangePending; + // The listed location as a storable; set for archive folders, which have no WinRT equivalent + private StorableWithPath? currentStorable; + /// /// Lists into filesAndFolders. Returns 3 on success and -1 on failure. /// @@ -38,6 +46,10 @@ private async Task EnumerateLinuxFolderAsync(string path, CancellationToken if (path.StartsWith(Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.Ordinal)) return await EnumerateLinuxTrashAsync(path, cancellationToken); + currentStorable = null; + if (FileExtensionHelpers.IsZipPath(path)) + return await EnumerateLinuxArchiveAsync(path, cancellationToken, library); + if (!Directory.Exists(path)) { ShowLocationInaccessibleOrMissing(path); @@ -148,6 +160,127 @@ await Task.Run(async () => return 3; } + /// + /// Lists an archive root or a folder inside an archive through (read-only, no watcher). + /// Returns 5 on success and -1 on failure. + /// + private async Task EnumerateLinuxArchiveAsync(string path, CancellationToken cancellationToken, LibraryItem? library) + { + HasNoWatcher = true; + + StorableResult resolved; + try + { + resolved = await Ioc.Default.GetRequiredService().TryGetAsync(path, cancellationToken); + } + catch (OperationCanceledException) + { + // Not our token: the password prompt was dismissed + if (!cancellationToken.IsCancellationRequested) + ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); + return -1; + } + + if (resolved.Item is not ArchiveFolder folder) + { + if (resolved.Status is StorableStatus.Error) + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); + else + ShowLocationInaccessibleOrMissing(path); + return -1; + } + + currentStorable = StorableWithPath.FromStorable(path, folder); + + var containerPath = FileExtensionHelpers.GetArchiveContainerPath(path) ?? path; + var containerModified = File.GetLastWriteTime(containerPath); + CurrentFolder = library ?? new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemPropertiesInitialized = true, + ItemNameRaw = folder.Name, + ItemDateModifiedReal = containerModified, + ItemDateCreatedReal = containerModified, + ItemType = folderTypeTextLocalized, + FileImage = null, + LoadFileIcon = false, + ItemPath = path, + FileSize = null, + FileSizeBytes = 0, + }; + + var iconCache = Ioc.Default.GetRequiredService(); + var iconSize = GetPreloadIconSize(); + var fileTypeText = Strings.File.GetLocalizedResource(); + + try + { + await Task.Run(async () => + { + var items = new List(); + await foreach (var child in folder.GetItemsAsync(StorableType.All, cancellationToken)) + { + var isFolder = child is IFolder; + var modified = child is ArchiveEntryFile { Entry.Modified: { } entryModified } ? entryModified.ToLocalTime() : containerModified; + var item = new ListedItem(null) + { + PrimaryItemAttribute = isFolder ? StorageItemTypes.Folder : StorageItemTypes.File, + ItemNameRaw = child.Name, + ItemDateModifiedReal = modified, + ItemDateCreatedReal = modified, + FileImage = null, + Opacity = 1d, + LoadFileIcon = false, + ItemPath = child.Id, + FileSize = null, + FileSizeBytes = 0, + ItemType = folderTypeTextLocalized, + }; + + if (child is ArchiveEntryFile file) + { + item.FileExtension = Path.GetExtension(child.Name); + var localizedType = string.IsNullOrEmpty(item.FileExtension) ? null : FileTypesHelper.GetLocalizedTypeName(item.FileExtension); + item.ItemType = !string.IsNullOrEmpty(localizedType) ? localizedType + : string.IsNullOrEmpty(item.FileExtension) ? fileTypeText : item.FileExtension.Trim('.') + " " + fileTypeText; + item.FileSizeBytes = file.Entry.Size; + item.FileSize = file.Entry.Size.ToSizeString(); + } + + try { item.PreloadedIconData = await iconCache.GetIconAsync(item.ItemPath, item.FileExtension, isFolder, iconSize); } + catch (Exception ex) { App.Logger.LogWarning(ex, "Could not load icon for an archive entry"); } + + items.Add(item); + } + + if (cancellationToken.IsCancellationRequested) + return; + + filesAndFolders.AddRange(items); + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + }, cancellationToken); + } + catch (OperationCanceledException) + { + if (!cancellationToken.IsCancellationRequested) + ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); + return -1; + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Could not list an archive"); + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); + return -1; + } + + if (cancellationToken.IsCancellationRequested) + return -1; + + IsLocationUnavailable = false; + return 5; + } + private async Task LoadLinuxRepositoryPropertiesAsync(CancellationToken cancellationToken) { if (!IsValidGitDirectory || EnabledGitProperties is GitProperties.None) diff --git a/src/Files.App/ViewModels/ShellViewModel.Windows.cs b/src/Files.App/ViewModels/ShellViewModel.Windows.cs new file mode 100644 index 000000000000..772e11ccca35 --- /dev/null +++ b/src/Files.App/ViewModels/ShellViewModel.Windows.cs @@ -0,0 +1,938 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Search; +using Windows.Win32; +using Windows.Win32.System.SystemServices; +using static Files.App.Helpers.Win32PInvoke; +using ByteSize = ByteSizeLib.ByteSize; +using FileAttributes = System.IO.FileAttributes; + +namespace Files.App.ViewModels +{ + /// + /// Windows folder listing (FindFirstFileEx, StorageFolder queries), ReadDirectoryChangesW watchers, alternate data streams and + /// Recycle Bin shell items of . Kept byte-identical to upstream where possible. + /// + public sealed partial class ShellViewModel + { + private async void RecycleBinRefreshRequestedAsync(object? sender, FileSystemEventArgs e) + { + if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) + return; + + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + RefreshItems(null); + }); + } + + private async void RecycleBinItemDeletedAsync(object? sender, FileSystemEventArgs e) + { + if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) + return; + + var removedItem = await RemoveFileOrFolderAsync(e.FullPath); + + if (removedItem is not null) + await ApplyFilesAndFoldersChangesAsync(); + } + + private async void RecycleBinItemCreatedAsync(object? sender, FileSystemEventArgs e) + { + if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) + return; + + using var folderItem = SafetyExtensions.IgnoreExceptions(() => new ShellItem(e.FullPath)); + if (folderItem is null) + return; + + var shellFileItem = ShellFolderExtensions.GetShellFileItem(folderItem); + if (shellFileItem is null) + throw new InvalidDataException("The recycle-bin item could not be converted to a shell item."); + + var newListedItem = await AddFileOrFolderFromShellFile(shellFileItem); + if (newListedItem is null) + return; + + await AddFileOrFolderAsync(newListedItem); + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + } + + partial void ApplyDriveStorageDetails(ListedItem item, IDictionary properties) + { + // Drive Storage Details + if (properties["System.SFGAOFlags"] is uint attributesRaw && + properties["System.Capacity"] is ulong capacityRaw && + properties["System.FreeSpace"] is ulong freeSpaceRaw && + ((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_REMOVABLE) && + !((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_FILESYSTEM)) + { + var maxSpace = ByteSize.FromBytes(capacityRaw); + var freeSpace = ByteSize.FromBytes(freeSpaceRaw); + + item.MaxSpace = maxSpace; + item.SpaceUsed = maxSpace - freeSpace; + item.FileSize = string.Format(Strings.DriveFreeSpaceAndCapacity.GetLocalizedResource(), freeSpace.ToSizeString(), maxSpace.ToSizeString()); + item.ShowDriveStorageDetails = true; + } + } + + private async Task PromptToUnlockBitlockerIfLockedAsync(string path, string pathRoot) + { + try + { + var rootFolder = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path)); + if (await FolderHelpers.CheckBitlockerStatusAsync(rootFolder, path)) + await ContextMenu.InvokeVerb("unlock-bde", pathRoot); + } + catch (Exception ex) + { + // Runs detached, so swallow: the property probe or the unlock-bde shell verb can throw (e.g. COMException) + App.Logger.LogWarning(ex, ex.Message); + } + } + + private async Task EnumerateWindowsFolderAsync(string path, CancellationToken cancellationToken, LibraryItem? library) + { + // Flag to use FindFirstFileExFromApp or StorageFolder enumeration - Use storage folder for Box Drive (#4629) + var isBoxFolder = CloudDrivesManager.Drives.FirstOrDefault(x => x.Text == "Box")?.Path?.TrimEnd('\\') is string boxFolder && path.StartsWith(boxFolder); + bool isWslDistro = path.StartsWith(@"\\wsl$\", StringComparison.OrdinalIgnoreCase) || path.StartsWith(@"\\wsl.localhost\", StringComparison.OrdinalIgnoreCase) + || path.Equals(@"\\wsl$", StringComparison.OrdinalIgnoreCase) || path.Equals(@"\\wsl.localhost", StringComparison.OrdinalIgnoreCase); + bool isMtp = path.StartsWith(@"\\?\", StringComparison.Ordinal); + bool isShellFolder = path.StartsWith(@"\\SHELL\", StringComparison.Ordinal); + bool isNetwork = path.StartsWith(@"\\", StringComparison.Ordinal) && + !isMtp && + !isShellFolder && + !isWslDistro; + bool isNetdisk = false; + + try + { + // Special handling for network drives + if (!isNetwork) + isNetdisk = await Task.Run(() => new DriveInfo(path).DriveType == System.IO.DriveType.Network); + } + catch { } + + bool isFtp = FtpHelpers.IsFtpPath(path); + bool enumFromStorageFolder = isBoxFolder || isFtp; + + BaseStorageFolder? rootFolder = null; + + if (isNetwork || isNetdisk) + { + var auth = await NetworkService.AuthenticateNetworkShare(path, cancellationToken); + if (!auth) + return -1; + } + + // Off the UI thread: FindFirstFileEx blocks until the SMB timeout on an unreachable share. + Win32PInvoke.SafeFindHandle? hFile = null; + WIN32_FIND_DATA findData = default; + int errorCode = 0; + if (!enumFromStorageFolder) + { + (hFile, findData, errorCode) = await Task.Run(() => + { + var hFileTsk = FindFirstFileExFromAppSafe( + path + "\\*.*", + FINDEX_INFO_LEVELS.FindExInfoBasic, + out WIN32_FIND_DATA findDataTsk, + FINDEX_SEARCH_OPS.FindExSearchNameMatch, + IntPtr.Zero, + FIND_FIRST_EX_LARGE_FETCH); + + return (hFileTsk, findDataTsk, hFileTsk.IsInvalid ? Marshal.GetLastWin32Error() : 0); + }) + .WithTimeoutAsync(TimeSpan.FromSeconds(5)); + } + + if (!enumFromStorageFolder && hFile is not null && !hFile.IsInvalid) + { + // Enumerate with the handle opened above; rootFolder only used for Bitlocker + currentStorageFolder = null; + } + else if (workingRoot is not null) + { + var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); + if (!res) + return -1; + + var storageFolder = res.Result + ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); + + currentStorageFolder = storageFolder; + rootFolder = storageFolder.Item; + enumFromStorageFolder = true; + } + else + { + var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); + if (res) + { + var storageFolder = res.Result + ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); + currentStorageFolder = storageFolder; + rootFolder = storageFolder.Item; + } + else if (res == FileSystemStatusCode.Unauthorized) + { + ShowLocationInaccessibleOrMissing(path); + + return -1; + } + else if (res == FileSystemStatusCode.NotFound) + { + ShowLocationInaccessibleOrMissing(path); + + return -1; + } + else + { + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged, res.ErrorCode.ToString()); + + return -1; + } + } + + var pathRoot = Path.GetPathRoot(path); + if (Path.IsPathRooted(path) && pathRoot == path) + { + // Off the critical path: a locked drive fails enumeration anyway, so don't block the listing on the BitLocker probe. + _ = PromptToUnlockBitlockerIfLockedAsync(path, pathRoot); + } + + HasNoWatcher = isFtp || isWslDistro || isMtp || currentStorageFolder?.Item is ZipStorageFolder; + + if (enumFromStorageFolder) + { + // The handle from the open above is unused on the storage-folder path. + hFile?.Dispose(); + + var basicProps = await rootFolder?.GetBasicPropertiesAsync(); + var currentFolder = library ?? new ListedItem(rootFolder?.FolderRelativeId ?? string.Empty) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemPropertiesInitialized = true, + ItemNameRaw = rootFolder?.DisplayName ?? string.Empty, + ItemDateModifiedReal = basicProps.DateModified, + ItemType = rootFolder?.DisplayType ?? string.Empty, + FileImage = null, + LoadFileIcon = false, + ItemPath = string.IsNullOrEmpty(rootFolder?.Path) ? currentStorageFolder?.Path ?? string.Empty : rootFolder.Path, + FileSize = null, + FileSizeBytes = 0, + }; + + if (library is null) + currentFolder.ItemDateCreatedReal = rootFolder?.DateCreated ?? DateTimeOffset.Now; + + CurrentFolder = currentFolder; + await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); + + // Workaround for #7428 + return isBoxFolder ? 2 : 1; + } + else + { + var itemModifiedDate = DateTime.Now; + var itemCreatedDate = DateTime.Now; + + try + { + FileTimeToSystemTime(in findData.ftLastWriteTime, out var systemModifiedTimeOutput); + itemModifiedDate = systemModifiedTimeOutput.ToDateTime(); + + FileTimeToSystemTime(in findData.ftCreationTime, out SYSTEMTIME systemCreatedTimeOutput); + itemCreatedDate = systemCreatedTimeOutput.ToDateTime(); + } + catch (ArgumentException) + { + } + + var isHidden = (((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden); + var opacity = isHidden ? Constants.UI.DimItemOpacity : 1d; + + var currentFolder = library ?? new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemPropertiesInitialized = true, + ItemNameRaw = rootFolder?.DisplayName ?? Path.GetFileName(path.TrimEnd('\\')), + ItemDateModifiedReal = itemModifiedDate, + ItemDateCreatedReal = itemCreatedDate, + ItemType = folderTypeTextLocalized, + FileImage = null, + IsHiddenItem = isHidden, + Opacity = opacity, + LoadFileIcon = false, + ItemPath = path, + FileSize = null, + FileSizeBytes = 0, + }; + + CurrentFolder = currentFolder; + + if (hFile is null) + { + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); + + return -1; + } + else if (hFile.IsInvalid) + { + hFile.Dispose(); + await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); + + // errorCode == ERROR_ACCESS_DENIED + if (filesAndFolders.Count == 0 && errorCode == 0x5) + { + ShowLocationInaccessibleOrMissing(path); + + return -1; + } + + return 1; + } + else + { + await Task.Run(async () => + { + List fileList = await Win32StorageEnumerator.ListEntries(path, hFile, findData, cancellationToken, -1, GetPreloadIconSize(), intermediateAction: async (intermediateList) => + { + filesAndFolders.AddRange(intermediateList); + + // The stable sort lands new items in final position without reordering visible ones; capped for huge listings + if (filesAndFolders.Count <= 10_000) + await OrderFilesAndFoldersAsync(); + + await ApplyFilesAndFoldersChangesAsync(); + }); + + filesAndFolders.AddRange(fileList); + + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + // Not awaited here: with Low priority these don't run until the UI thread goes idle + // after the final list update, which would delay load completion and watcher setup. + // The desktop.ini task is awaited before applying the adaptive layout, which reads DesktopIni. + _ = dispatcherQueue.EnqueueOrInvokeAsync(CheckForSolutionFile, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); + desktopIniUpdateTask = dispatcherQueue.EnqueueOrInvokeAsync(() => + { + GetDesktopIniFileData(); + CheckForBackgroundImage(); + }, + Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); + }); + + // Cache the resolved folder so the post-enum switch reuses it. + currentStorageFolder ??= await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path)); + rootFolder ??= currentStorageFolder?.Item; + if (rootFolder is not null) + { + if (rootFolder.DisplayName is not null) + currentFolder.ItemNameRaw = rootFolder.DisplayName; + + if (!string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase)) + { + enumeratedCloudSyncStatus = await CheckCloudDriveSyncStatusAsync(rootFolder); + currentFolder.SyncStatusUI = CloudDriveSyncStatusUI.FromCloudDriveSyncStatus(enumeratedCloudSyncStatus.Value); + } + } + + return 0; + } + } + } + + private async Task EnumFromStorageFolderAsync(string path, BaseStorageFolder? rootFolder, StorageFolderWithPath? currentStorageFolder, CancellationToken cancellationToken) + { + if (rootFolder is null) + return; + + // Null when a concurrent navigation or dispose cleared the context; this enumeration is stale + if (currentStorageFolder is null) + return; + + if (rootFolder is IPasswordProtectedItem ppis) + ppis.PasswordRequestedCallback = async (item) => + { + await dispatcherQueue.EnqueueOrInvokeAsync(() => ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired)); + + return await UIFilesystemHelpers.RequestPassword(item); + }; + + try + { + await Task.Run(async () => + { + List finalList = await UniversalStorageEnumerator.ListEntries( + rootFolder, + currentStorageFolder, + cancellationToken, + -1, + GetPreloadIconSize(), + async (intermediateList) => + { + filesAndFolders.AddRange(intermediateList); + + // The stable sort lands new items in final position without reordering visible ones; capped for huge listings + if (filesAndFolders.Count <= 10_000) + await OrderFilesAndFoldersAsync(); + + await ApplyFilesAndFoldersChangesAsync(); + }); + + filesAndFolders.AddRange(finalList); + + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + }, cancellationToken); + + IsLocationUnavailable = false; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) // Password dialog dismissed + { + ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); + } + + if (rootFolder is IPasswordProtectedItem ppiu) + ppiu.PasswordRequestedCallback = null; + } + + private async Task WatchForStorageFolderChangesAsync(BaseStorageFolder? rootFolder) + { + if (rootFolder is null) + return; + + await Task.Run(() => + { + var options = new QueryOptions() + { + FolderDepth = FolderDepth.Shallow, + IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties + }; + + options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); + options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); + + if (rootFolder.AreQueryOptionsSupported(options)) + { + var itemQueryResult = rootFolder.CreateItemQueryWithOptions(options).ToStorageItemQueryResult() + ?? throw new InvalidOperationException("The folder query could not be converted to a storage query."); + + itemQueryResult.ContentsChanged += ItemQueryResult_ContentsChanged; + + // Just get one item to start getting notifications + var watchedItemsOperation = itemQueryResult.GetItemsAsync(0, 1); + + watcherCTS.Token.Register(() => + { + itemQueryResult.ContentsChanged -= ItemQueryResult_ContentsChanged; + watchedItemsOperation?.Cancel(); + }); + } + }); + } + + private void WatchForWin32FolderChanges(string? folderPath) + { + if (!Directory.Exists(folderPath)) + return; + + // NOTE: Suppressed NullReferenceException caused by EnableRaisingEvents + SafetyExtensions.IgnoreExceptions(() => + { + watcher = new FileSystemWatcher + { + Path = folderPath, + Filter = "*.*", + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName + }; + + watcher.Created += DirectoryWatcher_Changed; + watcher.Deleted += DirectoryWatcher_Changed; + watcher.Renamed += DirectoryWatcher_Changed; + watcher.EnableRaisingEvents = true; + }, App.Logger); + } + + private async void DirectoryWatcher_Changed(object sender, FileSystemEventArgs e) + { + Debug.WriteLine($"Directory watcher event: {e.ChangeType}, {e.FullPath}"); + + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + RefreshItems(null); + }); + } + + private async void ItemQueryResult_ContentsChanged(IStorageQueryResultBase sender, object args) + { + // Query options have to be reapplied otherwise old results are returned + var options = new QueryOptions() + { + FolderDepth = FolderDepth.Shallow, + IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties + }; + + options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); + options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); + + sender.ApplyNewQueryOptions(options); + + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + RefreshItems(null); + }); + } + + private void WatchForDirectoryChanges(string path, CloudDriveSyncStatus syncStatus) + { + // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. + if (isDisposed) + return; + + Debug.WriteLine($"WatchForDirectoryChanges: {path}"); + var hWatchDir = Win32PInvoke.CreateFileFromApp(path, 1, 1 | 2 | 4, + IntPtr.Zero, 3, (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, IntPtr.Zero); + if (hWatchDir.ToInt64() == -1) + return; + + var hasSyncStatus = syncStatus != CloudDriveSyncStatus.NotSynced && syncStatus != CloudDriveSyncStatus.Unknown; + + aProcessQueueAction ??= Task.Run(() => ProcessOperationQueueAsync(watcherCTS.Token, hasSyncStatus)); + + var aWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => + { + var buff = new byte[4096]; + var rand = Guid.NewGuid(); + var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE; + + if (hasSyncStatus) + notifyFilters |= FILE_NOTIFY_CHANGE_ATTRIBUTES; + + var overlapped = new NativeOverlapped(); + using var eventHandle = PInvoke.CreateEvent(null, false, false, null); + overlapped.EventHandle = eventHandle.DangerousGetHandle(); + const uint INFINITE = 0xFFFFFFFF; + + while (x.Status != AsyncStatus.Canceled) + { + unsafe + { + fixed (byte* pBuff = buff) + { + ref var notifyInformation = ref Unsafe.As(ref buff[0]); + if (x.Status != AsyncStatus.Canceled) + { + PInvoke.ReadDirectoryChanges( + new Windows.Win32.Foundation.HANDLE(hWatchDir), + pBuff, + 4096, + false, + (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, + null, + &overlapped, + null); + } + else + { + break; + } + + Debug.WriteLine("waiting: {0}", rand); + if (x.Status == AsyncStatus.Canceled) + break; + + var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); + Debug.WriteLine("wait done: {0}", rand); + + uint offset = 0; + ref var notifyInfo = ref Unsafe.As(ref buff[offset]); + if (x.Status == AsyncStatus.Canceled) + break; + + do + { + notifyInfo = ref Unsafe.As(ref buff[offset]); + string? FileName = null; + unsafe + { + fixed (char* name = notifyInfo.FileName) + { + FileName = Path.Combine(path, new string(name, 0, (int)notifyInfo.FileNameLength / 2)); + } + } + + uint action = notifyInfo.Action; + + Debug.WriteLine("action: {0}", action); + + operationQueue.Enqueue((action, FileName)); + + offset += notifyInfo.NextEntryOffset; + } + while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); + + operationEvent.Set(); + + //ResetEvent(overlapped.hEvent); + Debug.WriteLine("Task running..."); + } + } + } + + operationQueue.Clear(); + + Debug.WriteLine("aWatcherAction done: {0}", rand); + }); + + watcherCTS.Token.Register(() => + { + if (aWatcherAction is not null) + { + aWatcherAction?.Cancel(); + + // Prevent duplicate execution of this block + aWatcherAction = null; + + Debug.WriteLine("watcher canceled"); + } + + CancelIoEx(hWatchDir, IntPtr.Zero); + CloseHandle(hWatchDir); + }); + } + + private void WatchForGitChanges() + { + // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. + if (isDisposed) + return; + + var hWatchDir = Win32PInvoke.CreateFileFromApp( + GitDirectory!, + 1, + 1 | 2 | 4, + IntPtr.Zero, + 3, + (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, + IntPtr.Zero); + + if (hWatchDir.ToInt64() == -1) + return; + + gitProcessQueueAction ??= Task.Run(() => ProcessGitChangesQueueAsync(watcherCTS.Token)); + + var gitWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => + { + var buff = new byte[4096]; + var rand = Guid.NewGuid(); + var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_CREATION; + + var overlapped = new NativeOverlapped(); + using var eventHandle = PInvoke.CreateEvent(null, false, false, null); + overlapped.EventHandle = eventHandle.DangerousGetHandle(); + const uint INFINITE = 0xFFFFFFFF; + + while (x.Status != AsyncStatus.Canceled) + { + unsafe + { + fixed (byte* pBuff = buff) + { + ref var notifyInformation = ref Unsafe.As(ref buff[0]); + if (x.Status == AsyncStatus.Canceled) + break; + + PInvoke.ReadDirectoryChanges( + new Windows.Win32.Foundation.HANDLE(hWatchDir), + pBuff, + 4096, + true, + (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, + null, + &overlapped, + null); + + if (x.Status == AsyncStatus.Canceled) + break; + + var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); + + uint offset = 0; + ref var notifyInfo = ref Unsafe.As(ref buff[offset]); + if (x.Status == AsyncStatus.Canceled) + break; + + do + { + notifyInfo = ref Unsafe.As(ref buff[offset]); + + uint action = notifyInfo.Action; + + gitChangesQueue.Enqueue(action); + + offset += notifyInfo.NextEntryOffset; + } + while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); + + gitChangedEvent.Set(); + } + } + } + + gitChangesQueue.Clear(); + }); + + watcherCTS.Token.Register(() => + { + if (gitWatcherAction is not null) + { + gitWatcherAction?.Cancel(); + + // Prevent duplicate execution of this block + gitWatcherAction = null; + } + + CancelIoEx(hWatchDir, IntPtr.Zero); + CloseHandle(hWatchDir); + }); + } + + private async Task ProcessGitChangesQueueAsync(CancellationToken cancellationToken) + { + const int DELAY = 200; + var sampler = new IntervalSampler(100); + int changes = 0; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + if (await gitChangedEvent.WaitAsync(DELAY, cancellationToken)) + { + gitChangedEvent.Reset(); + while (gitChangesQueue.TryDequeue(out var _)) + ++changes; + + if (changes != 0 && sampler.CheckNow()) + { + await dispatcherQueue.EnqueueOrInvokeAsync(() => GitDirectoryUpdated?.Invoke(null, null!)); + changes = 0; + } + } + } + } + catch { } + } + + private async Task ProcessOperationQueueAsync(CancellationToken cancellationToken, bool hasSyncStatus) + { + const uint FILE_ACTION_ADDED = 0x00000001; + const uint FILE_ACTION_REMOVED = 0x00000002; + const uint FILE_ACTION_MODIFIED = 0x00000003; + const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004; + const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005; + + const int UPDATE_BATCH_SIZE = 32; + var sampler = new IntervalSampler(200); + var updateQueue = new Queue(); + + var anyEdits = false; + ListedItem? lastItemAdded = null; + var rand = Guid.NewGuid(); + + // Call when any edits have occurred + async Task HandleChangesOccurredAsync() + { + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + + if (lastItemAdded is not null) + { + await RequestSelectionAsync([lastItemAdded]); + lastItemAdded = null; + } + + anyEdits = false; + } + + try + { + while (!cancellationToken.IsCancellationRequested) + { + if (await operationEvent.WaitAsync(200, cancellationToken)) + { + operationEvent.Reset(); + + while (operationQueue.TryDequeue(out var operation)) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + switch (operation.Action) + { + case FILE_ACTION_ADDED: + case FILE_ACTION_RENAMED_NEW_NAME: + lastItemAdded = await AddFileOrFolderAsync(operation.FileName); + if (lastItemAdded is not null) + anyEdits = true; + break; + + case FILE_ACTION_MODIFIED: + if (!updateQueue.Contains(operation.FileName)) + updateQueue.Enqueue(operation.FileName); + break; + + case FILE_ACTION_REMOVED: + var itemRemoved = await RemoveFileOrFolderAsync(operation.FileName); + if (itemRemoved is not null) + anyEdits = true; + break; + + case FILE_ACTION_RENAMED_OLD_NAME: + // Pair OLD_NAME with the following NEW_NAME so the item can be updated + // in place and stay at its current position instead of jumping to a new + // sorted slot after a rename (issue #4214). Leaving anyEdits false skips + // OrderFilesAndFoldersAsync; PropertyChanged keeps the visible name in sync. + if (operationQueue.TryPeek(out var nextOp) && nextOp.Action == FILE_ACTION_RENAMED_NEW_NAME && + filesAndFolders.ToList().FirstOrDefault(x => x.GetRequiredPath().Equals(operation.FileName, StringComparison.OrdinalIgnoreCase)) is { } renamed) + { + operationQueue.TryDequeue(out _); + var newPath = nextOp.FileName; + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + renamed.ItemPath = newPath; + renamed.ItemNameRaw = Path.GetFileName(newPath); + if (renamed.PrimaryItemAttribute == StorageItemTypes.File) + renamed.FileExtension = Path.GetExtension(newPath); + }); + } + else + { + var itemRenamedOld = await RemoveFileOrFolderAsync(operation.FileName); + if (itemRenamedOld is not null) + anyEdits = true; + } + break; + } + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, ex.Message); + } + + if (anyEdits && sampler.CheckNow()) + await HandleChangesOccurredAsync(); + } + + var itemsToUpdate = new List(); + for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) + itemsToUpdate.Add(updateQueue.Dequeue()); + + await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); + } + + if (updateQueue.Count > 0) + { + var itemsToUpdate = new List(); + for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) + itemsToUpdate.Add(updateQueue.Dequeue()); + + await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); + } + + if (anyEdits && sampler.CheckNow()) + await HandleChangesOccurredAsync(); + } + } + catch + { + // Prevent disposed cancellation token + } + + Debug.WriteLine("aProcessQueueAction done: {0}", rand); + } + + public Task AddFileOrFolderFromShellFile(ShellFileItem item) + { + return + item.IsFolder ? + UniversalStorageEnumerator.AddFolderAsync(ShellStorageFolder.FromShellItem(item), currentStorageFolder, addFilesCTS.Token) : + UniversalStorageEnumerator.AddFileAsync(ShellStorageFile.FromShellItem(item), currentStorageFolder, addFilesCTS.Token); + } + + partial void AddAlternateStreamItems(ListedItem item) + { + if (UserSettingsService.FoldersSettingsService.AreAlternateStreamsVisible) + { + // New file added, enumerate ADS + foreach (var ads in Win32Helper.GetAlternateStreams(item.GetRequiredPath())) + { + var adsItem = Win32StorageEnumerator.GetAlternateStream(ads, item); + filesAndFolders.Add(adsItem); + } + } + } + + private async Task AddFileOrFolderAsync(string fileOrFolderPath) + { + FINDEX_INFO_LEVELS findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoBasic; + var additionalFlags = FIND_FIRST_EX_CASE_SENSITIVE; + + IntPtr hFile = FindFirstFileExFromApp(fileOrFolderPath, findInfoLevel, out WIN32_FIND_DATA findData, FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, + additionalFlags); + if (hFile.ToInt64() == -1) + { + // If we cannot find the file (probably since it doesn't exist anymore) simply exit without adding it + return null; + } + + FindClose(hFile); + + var isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) == FileAttributes.System; + var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; + var startWithDot = findData.cFileName.StartsWith('.'); + if ((isHidden && + (!UserSettingsService.FoldersSettingsService.ShowHiddenItems || + (isSystem && !UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles))) || + (startWithDot && !UserSettingsService.FoldersSettingsService.ShowDotFiles)) + { + // Do not add to file list if hidden/system attribute is set and system/hidden file are not to be shown + return null; + } + + var parentPath = Directory.GetParent(fileOrFolderPath)?.FullName + ?? throw new InvalidOperationException("The added item does not have a parent directory."); + + ListedItem? listedItem; + + // FILE_ATTRIBUTE_DIRECTORY + if ((findData.dwFileAttributes & 0x10) > 0) + listedItem = await Win32StorageEnumerator.GetFolder(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); + else + listedItem = await Win32StorageEnumerator.GetFile(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); + + if (listedItem is null) + return null; + + await AddFileOrFolderAsync(listedItem); + + return listedItem; + } + } +} diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index 0fa2873c91ce..0cc4af04d4ed 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -18,9 +18,6 @@ using Windows.Storage; using Windows.Storage.FileProperties; using Windows.Storage.Search; -using Windows.Win32; -using Windows.Win32.System.SystemServices; -using static Files.App.Helpers.Win32PInvoke; using ByteSize = ByteSizeLib.ByteSize; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; using FileAttributes = System.IO.FileAttributes; @@ -758,9 +755,11 @@ public ShellViewModel(LayoutPreferencesManager folderSettingsViewModel) fileTagsSettingsService.OnTagsUpdated += FileTagsSettingsService_OnSettingUpdated; folderSizeProvider.SizeChanged += FolderSizeProvider_SizeChanged; folderSettings.LayoutModeChangeRequested += LayoutModeChangeRequested; +#if WINDOWS StorageTrashBinService.Watcher.ItemAdded += RecycleBinItemCreatedAsync; StorageTrashBinService.Watcher.ItemDeleted += RecycleBinItemDeletedAsync; StorageTrashBinService.Watcher.RefreshRequested += RecycleBinRefreshRequestedAsync; +#endif } private async void LayoutModeChangeRequested(object? sender, LayoutModeEventArgs e) @@ -768,50 +767,6 @@ private async void LayoutModeChangeRequested(object? sender, LayoutModeEventArgs await dispatcherQueue.EnqueueOrInvokeAsync(CheckForBackgroundImage, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); } - private async void RecycleBinRefreshRequestedAsync(object? sender, FileSystemEventArgs e) - { - if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) - return; - - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - RefreshItems(null); - }); - } - - private async void RecycleBinItemDeletedAsync(object? sender, FileSystemEventArgs e) - { - if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) - return; - - var removedItem = await RemoveFileOrFolderAsync(e.FullPath); - - if (removedItem is not null) - await ApplyFilesAndFoldersChangesAsync(); - } - - private async void RecycleBinItemCreatedAsync(object? sender, FileSystemEventArgs e) - { - if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) - return; - - using var folderItem = SafetyExtensions.IgnoreExceptions(() => new ShellItem(e.FullPath)); - if (folderItem is null) - return; - - var shellFileItem = ShellFolderExtensions.GetShellFileItem(folderItem); - if (shellFileItem is null) - throw new InvalidDataException("The recycle-bin item could not be converted to a shell item."); - - var newListedItem = await AddFileOrFolderFromShellFile(shellFileItem); - if (newListedItem is null) - return; - - await AddFileOrFolderAsync(newListedItem); - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - } - private async void FolderSizeProvider_SizeChanged(object? sender, Services.SizeProvider.SizeChangedEventArgs e) { try @@ -1832,21 +1787,7 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => var properties = extraProperties.Result ?? throw new InvalidOperationException("A folder-property lookup did not return properties."); - // Drive Storage Details - if (properties["System.SFGAOFlags"] is uint attributesRaw && - properties["System.Capacity"] is ulong capacityRaw && - properties["System.FreeSpace"] is ulong freeSpaceRaw && - ((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_REMOVABLE) && - !((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_FILESYSTEM)) - { - var maxSpace = ByteSize.FromBytes(capacityRaw); - var freeSpace = ByteSize.FromBytes(freeSpaceRaw); - - item.MaxSpace = maxSpace; - item.SpaceUsed = maxSpace - freeSpace; - item.FileSize = string.Format(Strings.DriveFreeSpaceAndCapacity.GetLocalizedResource(), freeSpace.ToSizeString(), maxSpace.ToSizeString()); - item.ShowDriveStorageDetails = true; - } + ApplyDriveStorageDetails(item, properties); } else @@ -2212,6 +2153,7 @@ private async Task RapidAddItemsToCollectionAsync(string? path, LibraryItem? lib switch (enumerated) { +#if WINDOWS // Enumerated with FindFirstFileExFromApp // Is folder synced to cloud storage? case 0: @@ -2244,6 +2186,7 @@ private async Task RapidAddItemsToCollectionAsync(string? path, LibraryItem? lib if (!HasNoWatcher) WatchForWin32FolderChanges(path); break; +#endif // Enumerated through IFileSystemEnumerator (Linux) case 3: @@ -2261,6 +2204,11 @@ private async Task RapidAddItemsToCollectionAsync(string? path, LibraryItem? lib WatchForLinuxTrashChanges(); break; + // Archive listed through IStorableResolver (Linux); read-only, so no watcher + case 5: + PageTypeUpdated?.Invoke(this, new PageTypeUpdatedEventArgs() { IsTypeCloudDrive = false }); + break; + // Enumeration failed case -1: default: @@ -2294,337 +2242,14 @@ public void CloseWatcher() watcherCTS = new CancellationTokenSource(); } - private async Task PromptToUnlockBitlockerIfLockedAsync(string path, string pathRoot) - { - try - { - var rootFolder = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path)); - if (await FolderHelpers.CheckBitlockerStatusAsync(rootFolder, path)) - await ContextMenu.InvokeVerb("unlock-bde", pathRoot); - } - catch (Exception ex) - { - // Runs detached, so swallow: the property probe or the unlock-bde shell verb can throw (e.g. COMException) - App.Logger.LogWarning(ex, ex.Message); - } - } - private async Task EnumerateItemsFromStandardFolderAsync(string path, CancellationToken cancellationToken, LibraryItem? library = null) { enumeratedCloudSyncStatus = null; -#if !WINDOWS - return await EnumerateLinuxFolderAsync(path, cancellationToken, library); -#else - - // Flag to use FindFirstFileExFromApp or StorageFolder enumeration - Use storage folder for Box Drive (#4629) - var isBoxFolder = CloudDrivesManager.Drives.FirstOrDefault(x => x.Text == "Box")?.Path?.TrimEnd('\\') is string boxFolder && path.StartsWith(boxFolder); - bool isWslDistro = path.StartsWith(@"\\wsl$\", StringComparison.OrdinalIgnoreCase) || path.StartsWith(@"\\wsl.localhost\", StringComparison.OrdinalIgnoreCase) - || path.Equals(@"\\wsl$", StringComparison.OrdinalIgnoreCase) || path.Equals(@"\\wsl.localhost", StringComparison.OrdinalIgnoreCase); - bool isMtp = path.StartsWith(@"\\?\", StringComparison.Ordinal); - bool isShellFolder = path.StartsWith(@"\\SHELL\", StringComparison.Ordinal); - bool isNetwork = path.StartsWith(@"\\", StringComparison.Ordinal) && - !isMtp && - !isShellFolder && - !isWslDistro; - bool isNetdisk = false; - - try - { - // Special handling for network drives - if (!isNetwork) - isNetdisk = await Task.Run(() => new DriveInfo(path).DriveType == System.IO.DriveType.Network); - } - catch { } - - bool isFtp = FtpHelpers.IsFtpPath(path); - bool enumFromStorageFolder = isBoxFolder || isFtp; - - BaseStorageFolder? rootFolder = null; - - if (isNetwork || isNetdisk) - { - var auth = await NetworkService.AuthenticateNetworkShare(path, cancellationToken); - if (!auth) - return -1; - } - - // Off the UI thread: FindFirstFileEx blocks until the SMB timeout on an unreachable share. - Win32PInvoke.SafeFindHandle? hFile = null; - WIN32_FIND_DATA findData = default; - int errorCode = 0; - if (!enumFromStorageFolder) - { - (hFile, findData, errorCode) = await Task.Run(() => - { - var hFileTsk = FindFirstFileExFromAppSafe( - path + "\\*.*", - FINDEX_INFO_LEVELS.FindExInfoBasic, - out WIN32_FIND_DATA findDataTsk, - FINDEX_SEARCH_OPS.FindExSearchNameMatch, - IntPtr.Zero, - FIND_FIRST_EX_LARGE_FETCH); - - return (hFileTsk, findDataTsk, hFileTsk.IsInvalid ? Marshal.GetLastWin32Error() : 0); - }) - .WithTimeoutAsync(TimeSpan.FromSeconds(5)); - } - - if (!enumFromStorageFolder && hFile is not null && !hFile.IsInvalid) - { - // Enumerate with the handle opened above; rootFolder only used for Bitlocker - currentStorageFolder = null; - } - else if (workingRoot is not null) - { - var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); - if (!res) - return -1; - - var storageFolder = res.Result - ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); - - currentStorageFolder = storageFolder; - rootFolder = storageFolder.Item; - enumFromStorageFolder = true; - } - else - { - var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); - if (res) - { - var storageFolder = res.Result - ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); - currentStorageFolder = storageFolder; - rootFolder = storageFolder.Item; - } - else if (res == FileSystemStatusCode.Unauthorized) - { - ShowLocationInaccessibleOrMissing(path); - - return -1; - } - else if (res == FileSystemStatusCode.NotFound) - { - ShowLocationInaccessibleOrMissing(path); - - return -1; - } - else - { - ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged, res.ErrorCode.ToString()); - - return -1; - } - } - - var pathRoot = Path.GetPathRoot(path); - if (Path.IsPathRooted(path) && pathRoot == path) - { - // Off the critical path: a locked drive fails enumeration anyway, so don't block the listing on the BitLocker probe. - _ = PromptToUnlockBitlockerIfLockedAsync(path, pathRoot); - } - - HasNoWatcher = isFtp || isWslDistro || isMtp || currentStorageFolder?.Item is ZipStorageFolder; - - if (enumFromStorageFolder) - { - // The handle from the open above is unused on the storage-folder path. - hFile?.Dispose(); - - var basicProps = await rootFolder?.GetBasicPropertiesAsync(); - var currentFolder = library ?? new ListedItem(rootFolder?.FolderRelativeId ?? string.Empty) - { - PrimaryItemAttribute = StorageItemTypes.Folder, - ItemPropertiesInitialized = true, - ItemNameRaw = rootFolder?.DisplayName ?? string.Empty, - ItemDateModifiedReal = basicProps.DateModified, - ItemType = rootFolder?.DisplayType ?? string.Empty, - FileImage = null, - LoadFileIcon = false, - ItemPath = string.IsNullOrEmpty(rootFolder?.Path) ? currentStorageFolder?.Path ?? string.Empty : rootFolder.Path, - FileSize = null, - FileSizeBytes = 0, - }; - - if (library is null) - currentFolder.ItemDateCreatedReal = rootFolder?.DateCreated ?? DateTimeOffset.Now; - - CurrentFolder = currentFolder; - await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); - - // Workaround for #7428 - return isBoxFolder ? 2 : 1; - } - else - { - var itemModifiedDate = DateTime.Now; - var itemCreatedDate = DateTime.Now; - - try - { - FileTimeToSystemTime(in findData.ftLastWriteTime, out var systemModifiedTimeOutput); - itemModifiedDate = systemModifiedTimeOutput.ToDateTime(); - - FileTimeToSystemTime(in findData.ftCreationTime, out SYSTEMTIME systemCreatedTimeOutput); - itemCreatedDate = systemCreatedTimeOutput.ToDateTime(); - } - catch (ArgumentException) - { - } - - var isHidden = (((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden); - var opacity = isHidden ? Constants.UI.DimItemOpacity : 1d; - - var currentFolder = library ?? new ListedItem(null) - { - PrimaryItemAttribute = StorageItemTypes.Folder, - ItemPropertiesInitialized = true, - ItemNameRaw = rootFolder?.DisplayName ?? Path.GetFileName(path.TrimEnd('\\')), - ItemDateModifiedReal = itemModifiedDate, - ItemDateCreatedReal = itemCreatedDate, - ItemType = folderTypeTextLocalized, - FileImage = null, - IsHiddenItem = isHidden, - Opacity = opacity, - LoadFileIcon = false, - ItemPath = path, - FileSize = null, - FileSizeBytes = 0, - }; - - CurrentFolder = currentFolder; - - if (hFile is null) - { - ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); - - return -1; - } - else if (hFile.IsInvalid) - { - hFile.Dispose(); - await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); - - // errorCode == ERROR_ACCESS_DENIED - if (filesAndFolders.Count == 0 && errorCode == 0x5) - { - ShowLocationInaccessibleOrMissing(path); - - return -1; - } - - return 1; - } - else - { - await Task.Run(async () => - { - List fileList = await Win32StorageEnumerator.ListEntries(path, hFile, findData, cancellationToken, -1, GetPreloadIconSize(), intermediateAction: async (intermediateList) => - { - filesAndFolders.AddRange(intermediateList); - - // The stable sort lands new items in final position without reordering visible ones; capped for huge listings - if (filesAndFolders.Count <= 10_000) - await OrderFilesAndFoldersAsync(); - - await ApplyFilesAndFoldersChangesAsync(); - }); - - filesAndFolders.AddRange(fileList); - - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - // Not awaited here: with Low priority these don't run until the UI thread goes idle - // after the final list update, which would delay load completion and watcher setup. - // The desktop.ini task is awaited before applying the adaptive layout, which reads DesktopIni. - _ = dispatcherQueue.EnqueueOrInvokeAsync(CheckForSolutionFile, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); - desktopIniUpdateTask = dispatcherQueue.EnqueueOrInvokeAsync(() => - { - GetDesktopIniFileData(); - CheckForBackgroundImage(); - }, - Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); - }); - - // Cache the resolved folder so the post-enum switch reuses it. - currentStorageFolder ??= await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path)); - rootFolder ??= currentStorageFolder?.Item; - if (rootFolder is not null) - { - if (rootFolder.DisplayName is not null) - currentFolder.ItemNameRaw = rootFolder.DisplayName; - - if (!string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase)) - { - enumeratedCloudSyncStatus = await CheckCloudDriveSyncStatusAsync(rootFolder); - currentFolder.SyncStatusUI = CloudDriveSyncStatusUI.FromCloudDriveSyncStatus(enumeratedCloudSyncStatus.Value); - } - } - - return 0; - } - } -#endif - } - - private async Task EnumFromStorageFolderAsync(string path, BaseStorageFolder? rootFolder, StorageFolderWithPath? currentStorageFolder, CancellationToken cancellationToken) - { #if WINDOWS - if (rootFolder is null) - return; - - // Null when a concurrent navigation or dispose cleared the context; this enumeration is stale - if (currentStorageFolder is null) - return; - - if (rootFolder is IPasswordProtectedItem ppis) - ppis.PasswordRequestedCallback = async (item) => - { - await dispatcherQueue.EnqueueOrInvokeAsync(() => ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired)); - - return await UIFilesystemHelpers.RequestPassword(item); - }; - - try - { - await Task.Run(async () => - { - List finalList = await UniversalStorageEnumerator.ListEntries( - rootFolder, - currentStorageFolder, - cancellationToken, - -1, - GetPreloadIconSize(), - async (intermediateList) => - { - filesAndFolders.AddRange(intermediateList); - - // The stable sort lands new items in final position without reordering visible ones; capped for huge listings - if (filesAndFolders.Count <= 10_000) - await OrderFilesAndFoldersAsync(); - - await ApplyFilesAndFoldersChangesAsync(); - }); - - filesAndFolders.AddRange(finalList); - - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - }, cancellationToken); - - IsLocationUnavailable = false; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) // Password dialog dismissed - { - ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); - } - - if (rootFolder is IPasswordProtectedItem ppiu) - ppiu.PasswordRequestedCallback = null; + return await EnumerateWindowsFolderAsync(path, cancellationToken, library); #else - // LINUX-TODO(shell): enumerate storables for non-local locations in P4-E. - await Task.CompletedTask; + return await EnumerateLinuxFolderAsync(path, cancellationToken, library); #endif } @@ -2713,6 +2338,7 @@ public void CheckForBackgroundImage() public async Task CheckCloudDriveSyncStatusAsync(IStorageItem? item) { + // LINUX-TODO(props): placeholder status comes from the Windows property system, which Uno doesn't back; Linux gets Unknown. int? syncStatus = null; if (item is BaseStorageFile file && file.Properties is not null) { @@ -2744,8 +2370,13 @@ public async Task CheckCloudDriveSyncStatusAsync(IStorageI return (CloudDriveSyncStatus)syncStatus; } + // Fills the drive capacity columns of removable shell folders (SFGAO flags); Windows only + partial void ApplyDriveStorageDetails(ListedItem item, IDictionary properties); + private async Task>?> GetExtraProperties(IStorageItem matchingStorageItem) { + // LINUX-TODO(props): Uno has no Windows property system behind RetrievePropertiesAsync, so dimensions, duration, version and + // drive capacity stay empty on Linux. Read them from a metadata service when those columns are ported. if (matchingStorageItem is BaseStorageFile file && file.Properties != null) return await FilesystemTasks.Wrap(() => file.Properties.RetrievePropertiesAsync((string[])["System.Image.Dimensions", "System.Media.Duration", "System.FileVersion"]).AsTask()); @@ -2755,476 +2386,8 @@ public async Task CheckCloudDriveSyncStatusAsync(IStorageI return null; } - private async Task WatchForStorageFolderChangesAsync(BaseStorageFolder? rootFolder) - { - if (rootFolder is null) - return; - - await Task.Run(() => - { - var options = new QueryOptions() - { - FolderDepth = FolderDepth.Shallow, - IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties - }; - - options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); - options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); - - if (rootFolder.AreQueryOptionsSupported(options)) - { - var itemQueryResult = rootFolder.CreateItemQueryWithOptions(options).ToStorageItemQueryResult() - ?? throw new InvalidOperationException("The folder query could not be converted to a storage query."); - - itemQueryResult.ContentsChanged += ItemQueryResult_ContentsChanged; - - // Just get one item to start getting notifications - var watchedItemsOperation = itemQueryResult.GetItemsAsync(0, 1); - - watcherCTS.Token.Register(() => - { - itemQueryResult.ContentsChanged -= ItemQueryResult_ContentsChanged; - watchedItemsOperation?.Cancel(); - }); - } - }); - } - - private void WatchForWin32FolderChanges(string? folderPath) - { - if (!Directory.Exists(folderPath)) - return; - - // NOTE: Suppressed NullReferenceException caused by EnableRaisingEvents - SafetyExtensions.IgnoreExceptions(() => - { - watcher = new FileSystemWatcher - { - Path = folderPath, - Filter = "*.*", - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName - }; - - watcher.Created += DirectoryWatcher_Changed; - watcher.Deleted += DirectoryWatcher_Changed; - watcher.Renamed += DirectoryWatcher_Changed; - watcher.EnableRaisingEvents = true; - }, App.Logger); - } - - private async void DirectoryWatcher_Changed(object sender, FileSystemEventArgs e) - { - Debug.WriteLine($"Directory watcher event: {e.ChangeType}, {e.FullPath}"); - - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - RefreshItems(null); - }); - } - - private async void ItemQueryResult_ContentsChanged(IStorageQueryResultBase sender, object args) - { - // Query options have to be reapplied otherwise old results are returned - var options = new QueryOptions() - { - FolderDepth = FolderDepth.Shallow, - IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties - }; - - options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); - options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); - - sender.ApplyNewQueryOptions(options); - - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - RefreshItems(null); - }); - } - - private void WatchForDirectoryChanges(string path, CloudDriveSyncStatus syncStatus) - { - // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. - if (isDisposed) - return; - - Debug.WriteLine($"WatchForDirectoryChanges: {path}"); - var hWatchDir = Win32PInvoke.CreateFileFromApp(path, 1, 1 | 2 | 4, - IntPtr.Zero, 3, (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, IntPtr.Zero); - if (hWatchDir.ToInt64() == -1) - return; - - var hasSyncStatus = syncStatus != CloudDriveSyncStatus.NotSynced && syncStatus != CloudDriveSyncStatus.Unknown; - - aProcessQueueAction ??= Task.Run(() => ProcessOperationQueueAsync(watcherCTS.Token, hasSyncStatus)); - - var aWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => - { - var buff = new byte[4096]; - var rand = Guid.NewGuid(); - var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE; - - if (hasSyncStatus) - notifyFilters |= FILE_NOTIFY_CHANGE_ATTRIBUTES; - - var overlapped = new NativeOverlapped(); - using var eventHandle = PInvoke.CreateEvent(null, false, false, null); - overlapped.EventHandle = eventHandle.DangerousGetHandle(); - const uint INFINITE = 0xFFFFFFFF; - - while (x.Status != AsyncStatus.Canceled) - { - unsafe - { - fixed (byte* pBuff = buff) - { - ref var notifyInformation = ref Unsafe.As(ref buff[0]); - if (x.Status != AsyncStatus.Canceled) - { - PInvoke.ReadDirectoryChanges( - new Windows.Win32.Foundation.HANDLE(hWatchDir), - pBuff, - 4096, - false, - (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, - null, - &overlapped, - null); - } - else - { - break; - } - - Debug.WriteLine("waiting: {0}", rand); - if (x.Status == AsyncStatus.Canceled) - break; - - var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); - Debug.WriteLine("wait done: {0}", rand); - - uint offset = 0; - ref var notifyInfo = ref Unsafe.As(ref buff[offset]); - if (x.Status == AsyncStatus.Canceled) - break; - - do - { - notifyInfo = ref Unsafe.As(ref buff[offset]); - string? FileName = null; - unsafe - { - fixed (char* name = notifyInfo.FileName) - { - FileName = Path.Combine(path, new string(name, 0, (int)notifyInfo.FileNameLength / 2)); - } - } - - uint action = notifyInfo.Action; - - Debug.WriteLine("action: {0}", action); - - operationQueue.Enqueue((action, FileName)); - - offset += notifyInfo.NextEntryOffset; - } - while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); - - operationEvent.Set(); - - //ResetEvent(overlapped.hEvent); - Debug.WriteLine("Task running..."); - } - } - } - - operationQueue.Clear(); - - Debug.WriteLine("aWatcherAction done: {0}", rand); - }); - - watcherCTS.Token.Register(() => - { - if (aWatcherAction is not null) - { - aWatcherAction?.Cancel(); - - // Prevent duplicate execution of this block - aWatcherAction = null; - - Debug.WriteLine("watcher canceled"); - } - - CancelIoEx(hWatchDir, IntPtr.Zero); - CloseHandle(hWatchDir); - }); - } - - private void WatchForGitChanges() - { - // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. - if (isDisposed) - return; - - var hWatchDir = Win32PInvoke.CreateFileFromApp( - GitDirectory!, - 1, - 1 | 2 | 4, - IntPtr.Zero, - 3, - (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, - IntPtr.Zero); - - if (hWatchDir.ToInt64() == -1) - return; - - gitProcessQueueAction ??= Task.Run(() => ProcessGitChangesQueueAsync(watcherCTS.Token)); - - var gitWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => - { - var buff = new byte[4096]; - var rand = Guid.NewGuid(); - var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_CREATION; - - var overlapped = new NativeOverlapped(); - using var eventHandle = PInvoke.CreateEvent(null, false, false, null); - overlapped.EventHandle = eventHandle.DangerousGetHandle(); - const uint INFINITE = 0xFFFFFFFF; - - while (x.Status != AsyncStatus.Canceled) - { - unsafe - { - fixed (byte* pBuff = buff) - { - ref var notifyInformation = ref Unsafe.As(ref buff[0]); - if (x.Status == AsyncStatus.Canceled) - break; - - PInvoke.ReadDirectoryChanges( - new Windows.Win32.Foundation.HANDLE(hWatchDir), - pBuff, - 4096, - true, - (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, - null, - &overlapped, - null); - - if (x.Status == AsyncStatus.Canceled) - break; - - var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); - - uint offset = 0; - ref var notifyInfo = ref Unsafe.As(ref buff[offset]); - if (x.Status == AsyncStatus.Canceled) - break; - - do - { - notifyInfo = ref Unsafe.As(ref buff[offset]); - - uint action = notifyInfo.Action; - - gitChangesQueue.Enqueue(action); - - offset += notifyInfo.NextEntryOffset; - } - while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); - - gitChangedEvent.Set(); - } - } - } - - gitChangesQueue.Clear(); - }); - - watcherCTS.Token.Register(() => - { - if (gitWatcherAction is not null) - { - gitWatcherAction?.Cancel(); - - // Prevent duplicate execution of this block - gitWatcherAction = null; - } - - CancelIoEx(hWatchDir, IntPtr.Zero); - CloseHandle(hWatchDir); - }); - } - - private async Task ProcessGitChangesQueueAsync(CancellationToken cancellationToken) - { - const int DELAY = 200; - var sampler = new IntervalSampler(100); - int changes = 0; - - try - { - while (!cancellationToken.IsCancellationRequested) - { - if (await gitChangedEvent.WaitAsync(DELAY, cancellationToken)) - { - gitChangedEvent.Reset(); - while (gitChangesQueue.TryDequeue(out var _)) - ++changes; - - if (changes != 0 && sampler.CheckNow()) - { - await dispatcherQueue.EnqueueOrInvokeAsync(() => GitDirectoryUpdated?.Invoke(null, null!)); - changes = 0; - } - } - } - } - catch { } - } - - private async Task ProcessOperationQueueAsync(CancellationToken cancellationToken, bool hasSyncStatus) - { - const uint FILE_ACTION_ADDED = 0x00000001; - const uint FILE_ACTION_REMOVED = 0x00000002; - const uint FILE_ACTION_MODIFIED = 0x00000003; - const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004; - const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005; - - const int UPDATE_BATCH_SIZE = 32; - var sampler = new IntervalSampler(200); - var updateQueue = new Queue(); - - var anyEdits = false; - ListedItem? lastItemAdded = null; - var rand = Guid.NewGuid(); - - // Call when any edits have occurred - async Task HandleChangesOccurredAsync() - { - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - - if (lastItemAdded is not null) - { - await RequestSelectionAsync([lastItemAdded]); - lastItemAdded = null; - } - - anyEdits = false; - } - - try - { - while (!cancellationToken.IsCancellationRequested) - { - if (await operationEvent.WaitAsync(200, cancellationToken)) - { - operationEvent.Reset(); - - while (operationQueue.TryDequeue(out var operation)) - { - if (cancellationToken.IsCancellationRequested) - break; - - try - { - switch (operation.Action) - { - case FILE_ACTION_ADDED: - case FILE_ACTION_RENAMED_NEW_NAME: - lastItemAdded = await AddFileOrFolderAsync(operation.FileName); - if (lastItemAdded is not null) - anyEdits = true; - break; - - case FILE_ACTION_MODIFIED: - if (!updateQueue.Contains(operation.FileName)) - updateQueue.Enqueue(operation.FileName); - break; - - case FILE_ACTION_REMOVED: - var itemRemoved = await RemoveFileOrFolderAsync(operation.FileName); - if (itemRemoved is not null) - anyEdits = true; - break; - - case FILE_ACTION_RENAMED_OLD_NAME: - // Pair OLD_NAME with the following NEW_NAME so the item can be updated - // in place and stay at its current position instead of jumping to a new - // sorted slot after a rename (issue #4214). Leaving anyEdits false skips - // OrderFilesAndFoldersAsync; PropertyChanged keeps the visible name in sync. - if (operationQueue.TryPeek(out var nextOp) && nextOp.Action == FILE_ACTION_RENAMED_NEW_NAME && - filesAndFolders.ToList().FirstOrDefault(x => x.GetRequiredPath().Equals(operation.FileName, StringComparison.OrdinalIgnoreCase)) is { } renamed) - { - operationQueue.TryDequeue(out _); - var newPath = nextOp.FileName; - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - renamed.ItemPath = newPath; - renamed.ItemNameRaw = Path.GetFileName(newPath); - if (renamed.PrimaryItemAttribute == StorageItemTypes.File) - renamed.FileExtension = Path.GetExtension(newPath); - }); - } - else - { - var itemRenamedOld = await RemoveFileOrFolderAsync(operation.FileName); - if (itemRenamedOld is not null) - anyEdits = true; - } - break; - } - } - catch (Exception ex) - { - App.Logger.LogWarning(ex, ex.Message); - } - - if (anyEdits && sampler.CheckNow()) - await HandleChangesOccurredAsync(); - } - - var itemsToUpdate = new List(); - for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) - itemsToUpdate.Add(updateQueue.Dequeue()); - - await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); - } - - if (updateQueue.Count > 0) - { - var itemsToUpdate = new List(); - for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) - itemsToUpdate.Add(updateQueue.Dequeue()); - - await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); - } - - if (anyEdits && sampler.CheckNow()) - await HandleChangesOccurredAsync(); - } - } - catch - { - // Prevent disposed cancellation token - } - - Debug.WriteLine("aProcessQueueAction done: {0}", rand); - } - - public Task AddFileOrFolderFromShellFile(ShellFileItem item) - { -#if WINDOWS - return - item.IsFolder ? - UniversalStorageEnumerator.AddFolderAsync(ShellStorageFolder.FromShellItem(item), currentStorageFolder, addFilesCTS.Token) : - UniversalStorageEnumerator.AddFileAsync(ShellStorageFile.FromShellItem(item), currentStorageFolder, addFilesCTS.Token); -#else - // LINUX-TODO(shell): replace the Windows Shell item factory in P4-E. - return Task.FromResult(null); -#endif - } + // Lists the alternate data streams of a newly added item when they're shown; NTFS only + partial void AddAlternateStreamItems(ListedItem item); private async Task AddFileOrFolderAsync(ListedItem? item) { @@ -3244,74 +2407,12 @@ private async Task AddFileOrFolderAsync(ListedItem? item) { filesAndFolders.Add(item); -#if WINDOWS - if (UserSettingsService.FoldersSettingsService.AreAlternateStreamsVisible) - { - // New file added, enumerate ADS - foreach (var ads in Win32Helper.GetAlternateStreams(item.GetRequiredPath())) - { - var adsItem = Win32StorageEnumerator.GetAlternateStream(ads, item); - filesAndFolders.Add(adsItem); - } - } -#endif + AddAlternateStreamItems(item); } enumFolderSemaphore.Release(); } - private async Task AddFileOrFolderAsync(string fileOrFolderPath) - { -#if WINDOWS - FINDEX_INFO_LEVELS findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoBasic; - var additionalFlags = FIND_FIRST_EX_CASE_SENSITIVE; - - IntPtr hFile = FindFirstFileExFromApp(fileOrFolderPath, findInfoLevel, out WIN32_FIND_DATA findData, FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, - additionalFlags); - if (hFile.ToInt64() == -1) - { - // If we cannot find the file (probably since it doesn't exist anymore) simply exit without adding it - return null; - } - - FindClose(hFile); - - var isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) == FileAttributes.System; - var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; - var startWithDot = findData.cFileName.StartsWith('.'); - if ((isHidden && - (!UserSettingsService.FoldersSettingsService.ShowHiddenItems || - (isSystem && !UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles))) || - (startWithDot && !UserSettingsService.FoldersSettingsService.ShowDotFiles)) - { - // Do not add to file list if hidden/system attribute is set and system/hidden file are not to be shown - return null; - } - - var parentPath = Directory.GetParent(fileOrFolderPath)?.FullName - ?? throw new InvalidOperationException("The added item does not have a parent directory."); - - ListedItem? listedItem; - - // FILE_ATTRIBUTE_DIRECTORY - if ((findData.dwFileAttributes & 0x10) > 0) - listedItem = await Win32StorageEnumerator.GetFolder(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); - else - listedItem = await Win32StorageEnumerator.GetFile(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); - - if (listedItem is null) - return null; - - await AddFileOrFolderAsync(listedItem); - - return listedItem; -#else - // LINUX-TODO(shell): replace the Win32 incremental item factory in P4-E. - await Task.CompletedTask; - return null; -#endif - } - private async Task<(ListedItem Item, CloudDriveSyncStatus? SyncStatus, long? Size, DateTimeOffset Created, DateTimeOffset Modified)?> GetFileOrFolderUpdateInfoAsync(ListedItem item, bool hasSyncStatus) { IStorageItem? storageItem = null; @@ -3646,9 +2747,11 @@ public void Dispose() updateTagGroupCTS?.Cancel(); App.Logger.LogInformation($"ShellViewModel.Dispose: CurrentFolder={LogPathHelper.RedactPath(CurrentFolder?.ItemPath)}"); +#if WINDOWS StorageTrashBinService.Watcher.ItemAdded -= RecycleBinItemCreatedAsync; StorageTrashBinService.Watcher.ItemDeleted -= RecycleBinItemDeletedAsync; StorageTrashBinService.Watcher.RefreshRequested -= RecycleBinRefreshRequestedAsync; +#endif UserSettingsService.OnSettingChangedEvent -= UserSettingsService_OnSettingChangedEvent; fileTagsSettingsService.OnSettingImportedEvent -= FileTagsSettingsService_OnSettingUpdated; fileTagsSettingsService.OnTagsUpdated -= FileTagsSettingsService_OnSettingUpdated; From 889d8483b9e6c7676f595bc7bf461a92cee83f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 23:06:25 +0300 Subject: [PATCH 2/2] Switch Win32 compat mode off on Linux (P4-Z) FilesWin32Compat now defaults to false and stays as an opt-in lens (defines FILES_WIN32_COMPAT). The desktop build compiles with 0 errors and without the CsWin32 prebuild: registry cloud detectors and MTP shell resolution move to *.Windows.cs, the remaining Win32 call sites get small #if WINDOWS blocks or Linux fallbacks, ICommonDialogService and IStartMenuService get desktop stubs, and IStorageItemWithPath.Item plus the unused desktop WinRT StorageHelpers members are gone. Linux CI builds Files.App with compat off as a blocking step. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .github/workflows/ci-linux.yml | 14 +++++++-- Directory.Build.props | 4 +-- docs/linux-port/PLAN.md | 9 ++++-- docs/linux-port/compat-off-baseline.txt | 2 -- docs/linux-port/phase2-errors.md | 5 +++ .../FileSystem/EmptyRecycleBinAction.cs | 4 +++ src/Files.App/Data/Items/ContextMenu.cs | 4 +++ src/Files.App/Data/Items/WidgetRecentItem.cs | 6 +++- .../Data/Models/PinnedFoldersManager.cs | 5 +++ .../Extensions/ShellNewEntryExtensions.cs | 2 ++ src/Files.App/Files.App.csproj | 4 +++ .../Helpers/Application/AppLifecycleHelper.cs | 8 +++++ .../Desktop/Services/DesktopStubServices.cs | 31 +++++++++++++++++++ .../PreviewPopupService.cs | 2 ++ ...ctor.cs => CloudDrivesDetector.Windows.cs} | 0 .../Utils/Cloud/Detector/CloudDetector.cs | 5 +++ ...tor.cs => GenericCloudDetector.Windows.cs} | 0 ...tor.cs => OXDriveCloudDetector.Windows.cs} | 0 .../Storage/Helpers/IStorageItemWithPath.cs | 3 +- .../{MtpHelpers.cs => MtpHelpers.Windows.cs} | 0 .../Utils/Storage/Helpers/StorableWithPath.cs | 2 ++ .../Storage/Helpers/StorageFileWithPath.cs | 2 ++ .../Storage/Helpers/StorageFolderWithPath.cs | 2 ++ .../Helpers/StorageHelpers.Storables.cs | 10 ++++++ .../Utils/Storage/Helpers/StorageHelpers.cs | 24 +------------- .../Storage/Helpers/StorageSenseHelper.cs | 5 +++ .../ViewModels/Layouts/BaseLayoutViewModel.cs | 4 +-- .../Properties/Items/LibraryProperties.cs | 7 +++++ .../NavigationToolbarViewModel.cs | 9 ++++-- .../UserControls/SidebarViewModel.cs | 6 +++- .../NetworkLocationsWidgetViewModel.cs | 4 +++ src/Files.App/Views/Layouts/BaseLayoutPage.cs | 16 +++++++++- 32 files changed, 156 insertions(+), 43 deletions(-) rename src/Files.App/Utils/Cloud/{CloudDrivesDetector.cs => CloudDrivesDetector.Windows.cs} (100%) rename src/Files.App/Utils/Cloud/Detector/{GenericCloudDetector.cs => GenericCloudDetector.Windows.cs} (100%) rename src/Files.App/Utils/Cloud/Detector/{OXDriveCloudDetector.cs => OXDriveCloudDetector.Windows.cs} (100%) rename src/Files.App/Utils/Storage/Helpers/{MtpHelpers.cs => MtpHelpers.Windows.cs} (100%) diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 6bfdfa6438c8..2f1a798bc881 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -71,9 +71,17 @@ jobs: - name: Build Files.App.Storage (Uno desktop) run: dotnet build src/Files.App.Storage/Files.App.Storage.csproj -c Release -f net10.0-desktop - # Informational until the Phase 2 error inventory (docs/linux-port/phase2-errors.md) reaches zero. - - name: Build Files.App (Uno desktop) - run: dotnet build src/Files.App/Files.App.csproj -c Release -f net10.0-desktop + # Blocking: Win32 compat mode is off (the default since P4-Z), so no Win32/Shell code and no CsWin32 assembly may be needed. + - name: Build Files.App (Uno desktop, Win32 compat off) + run: dotnet build src/Files.App/Files.App.csproj -c Release -f net10.0-desktop -p:FilesWin32Compat=false + + - name: Verify the desktop build did not build CsWin32 + shell: bash + run: | + if [ -d src/Files.App.CsWin32/bin ] || [ -d src/Files.App.CsWin32/obj ]; then + echo "::error::Files.App.CsWin32 was built for the desktop target; something still depends on Win32 compat mode." + exit 1 + fi - name: Build and test platform test projects (if any) shell: bash diff --git a/Directory.Build.props b/Directory.Build.props index 3db5d9e4871f..1c84d532f745 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,8 +9,8 @@ 10.0.26100.67-preview $(BaseTargetFramework)-windows$(TargetWindowsVersion) $(BaseTargetFramework)-desktop - - true + + false preview nullable diff --git a/docs/linux-port/PLAN.md b/docs/linux-port/PLAN.md index 89d443d8d8bc..dd7dfeea2141 100644 --- a/docs/linux-port/PLAN.md +++ b/docs/linux-port/PLAN.md @@ -43,7 +43,7 @@ The owner decided to focus on Linux first. Windows support is revisited only aft | 1 | Abstraction skeleton (`Files.Platform.*`, tests, DI) | serial, small | | 2 | **Linux bring-up: make `Files.App` compile and launch on Uno `net10.0-desktop`.** Retarget Controls + App to Uno. Exclude Windows-only folders (`Compile Remove`). Replace Win32/WinRT calls with interface calls, backed by stub or simple Linux implementations. Fix compile errors folder by folder. **✅ Done (2026-10-05):** the app compiles, launches and runs on Linux; the compat-off criterion moved to Phase 4. | parallel by folder ownership | | 3 | Real Linux backends per interface (enumeration, watcher, file ops, trash, launcher, thumbnails, drives, clipboard…), with unit tests | parallel, one agent per interface | -| 4 | Replace the legacy WinRT storage layer (`BaseStorageFile`/`Folder`) with OwlCore storables, then switch Win32 compat mode off (moved from Phase 2) | parallel work packages (see Phase 4 plan, Revision 3) | +| 4 | Replace the legacy WinRT storage layer (`BaseStorageFile`/`Folder`) with OwlCore storables, then switch Win32 compat mode off (moved from Phase 2). **✅ Done (2026-10-05, P4-Z):** compat mode is off by default and the desktop build has 0 errors, which also meets Phase 2's criterion | parallel work packages (see Phase 4 plan, Revision 3) | | 5 | UI blockers and UX parity: L-DND, SpeedGraph, StickyHeader, title bar, previews, single instance | parallel | | 6 | Desktop integration and packaging (FileManager1 D-Bus, `.desktop`, Flatpak/AppImage/AUR) | parallel | | 7 | Hardening (performance, HiDPI, themes, localisation) | parallel | @@ -53,7 +53,7 @@ The Phase 2 workstreams in §7.3 still define folder ownership. Their goal is no not "Windows code wrapped without behaviour change". ### Phase 2 build decisions (2026-10-04) -- **Win32 compat mode is the default** (`FilesWin32Compat=true` in `Directory.Build.props`). Existing Win32/Shell code compiles on Linux against the CsWin32 assembly, which is built automatically for Platform=x64. It cannot run on Linux, so any call that is reached at runtime must be replaced by a `Files.Platform` service. Switching compat mode off was Phase 2's exit criterion; since 2026-10-05 it is Phase 4's (P4-Z), because it depends on removing the legacy storage layer. +- **Win32 compat mode is off by default since P4-Z (2026-10-05)** (`FilesWin32Compat=false` in `Directory.Build.props`). The desktop build no longer compiles Win32/Shell code or builds the CsWin32 assembly. `-p:FilesWin32Compat=true` still works as an opt-in inventory lens: it compiles the Windows-only folders against CsWin32 and defines `FILES_WIN32_COMPAT`. Nothing compiled that way runs on Linux. Linux CI builds with compat off and fails on any error. (Until P4-Z compat mode was the default. Switching it off was Phase 2's exit criterion and became Phase 4's on 2026-10-05.) - **Nullable diagnostics are warnings on `-desktop`**, because Uno's annotations differ from WinUI's. Re-enable once the build is clean. - Stubbed behaviour is tagged `// LINUX-TODO()`. Use `rg -n LINUX-TODO src` to find the backlog. @@ -247,7 +247,8 @@ Each workstream: define its interface methods from real call sites, implement th (`Files.Core.Storage`) + the Phase 2 interfaces. `ShellViewModel` first, then properties, search, archives (`Zip*`), FTP, virtual folders. - Kept on a single owner (or a strictly serialized queue), because almost everything touches `ShellViewModel`. -- **Exit:** `Utils/Storage/StorageItems/*` is no longer referenced by the Linux build. +- **Exit:** `Utils/Storage/StorageItems/*` is no longer referenced by the Linux build. *Superseded by the Revision 3 exit below, which is met. The legacy items + (`FtpStorage*`, `SystemStorage*`, `ZipStorageFolder.IsZipPath`, `BaseStorage*`) still compile on Linux behind `LINUX-TODO(storage)` and remain follow-up work.* #### Phase 4 plan, Revision 3 (2026-10-05): drop the legacy storage layer, then switch Win32 compat off The two are one project: the legacy items (`Zip*`, `NativeStorageFile`, `VirtualStorageItem`, `Shell*`, `SystemStorageFolder`) call @@ -287,6 +288,8 @@ Add first, delete last; default build stays green. Contracts (P4-0, then frozen) | P4-Z | 1–2 | Flip `FilesWin32Compat=false`, fix leftovers, delete `#if WINDOWS` IStorageItem members, CI job blocking | all | strong | Order: P4-0 → wave 1a (K, A, B, C, H, I) → wave 1b (F, D, G, J) → E → Z. Critical path ≈ 10 working days. +**Status (2026-10-05):** all 14 packages are merged or in review; P4-E (#73) and P4-Z close the phase. Compat off by default, 0 errors, headless smoke +test passed (browse, copy/move, trash/restore, browse into a zip, Properties, search, live updates), and the Linux CI compat-off build is blocking. Gate after every merge: normal desktop build passes; `-p:FilesWin32Compat=false` error count ratchets down vs `docs/linux-port/compat-off-baseline.txt`; `rg --no-ignore 'BaseStorage|StorageItems\.|Windows\.Win32|PInvoke\.|Win32Helper\.'` on the WP's files (outside `*.Windows.cs`) → 0. Exit: compat off by default, 0 errors, headless smoke run passes, CI compat-off job blocking. diff --git a/docs/linux-port/compat-off-baseline.txt b/docs/linux-port/compat-off-baseline.txt index 0dfc7e761abe..ec88cc1608cf 100644 --- a/docs/linux-port/compat-off-baseline.txt +++ b/docs/linux-port/compat-off-baseline.txt @@ -1,4 +1,2 @@ # Compiler error keys of: dotnet build src/Files.App -f net10.0-desktop -p:FilesWin32Compat=false # One "file: error CODE: message" per line. Regenerate with scripts/linux/compat-off-check.sh --update. -src/Files.App/Data/Items/ContextMenu.cs: error CS0246: The type or namespace name 'MENU_ITEM_TYPE' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Data/Items/WidgetRecentItem.cs: error CS0246: The type or namespace name 'IShellItem' could not be found (are you missing a using directive or an assembly reference?) diff --git a/docs/linux-port/phase2-errors.md b/docs/linux-port/phase2-errors.md index af63bbe759cd..2ae4e5c76a07 100644 --- a/docs/linux-port/phase2-errors.md +++ b/docs/linux-port/phase2-errors.md @@ -1,5 +1,10 @@ # Phase 2: Files.App on Uno (net10.0-desktop) build inventory +> **Status (2026-10-05, P4-Z): resolved.** Win32 compat mode is off by default, and `dotnet build src/Files.App -f net10.0-desktop` reports +> 0 errors without building CsWin32. The last iterations went 2 → 53 → 2 → 0 errors (declaration errors hid the body errors), plus one more +> round after `IStorageItemWithPath.Item` became Windows-only (10 → 0). `-p:FilesWin32Compat=true` remains as an opt-in lens and still +> builds. The inventory below is historical. + Branch `linux/p2-head`. Generated from `dotnet build src/Files.App -f net10.0-desktop` (Uno.Sdk 6.7.30, Uno.WinUI 6.7.135). ## How the build is organised diff --git a/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs b/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs index 6e57587c0a9a..8e0939f003ae 100644 --- a/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs +++ b/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs @@ -58,7 +58,11 @@ await confirmationDialog.TryShowAsync() is ContentDialogResult.Primary) { var banner = StatusCenterHelper.AddCard_EmptyRecycleBin(ReturnResult.InProgress); +#if WINDOWS bool result = await STATask.Run(StorageTrashBinService.EmptyTrashBin, App.Logger); +#else + bool result = await Task.Run(StorageTrashBinService.EmptyTrashBin); +#endif StatusCenterViewModel.RemoveItem(banner); diff --git a/src/Files.App/Data/Items/ContextMenu.cs b/src/Files.App/Data/Items/ContextMenu.cs index 091657de6a25..399e6632e5bc 100644 --- a/src/Files.App/Data/Items/ContextMenu.cs +++ b/src/Files.App/Data/Items/ContextMenu.cs @@ -1,7 +1,9 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if WINDOWS || FILES_WIN32_COMPAT using Windows.Win32.UI.WindowsAndMessaging; +#endif namespace Files.App.Data.Items { @@ -31,7 +33,9 @@ public class Win32ContextMenuItem public int ID { get; set; } // Valid only in current menu to invoke item public string? Label { get; set; } public string? CommandString { get; set; } +#if WINDOWS || FILES_WIN32_COMPAT public MENU_ITEM_TYPE Type { get; set; } +#endif public List? SubItems { get; set; } } } diff --git a/src/Files.App/Data/Items/WidgetRecentItem.cs b/src/Files.App/Data/Items/WidgetRecentItem.cs index 5f27915f1c57..7cd7a94f3bc8 100644 --- a/src/Files.App/Data/Items/WidgetRecentItem.cs +++ b/src/Files.App/Data/Items/WidgetRecentItem.cs @@ -2,7 +2,9 @@ // SPDX-License-Identifier: MPL-2.0 using Microsoft.UI.Xaml.Media.Imaging; +#if WINDOWS || FILES_WIN32_COMPAT using Windows.Win32.UI.Shell; +#endif namespace Files.App.Data.Items { @@ -31,10 +33,12 @@ public BitmapImage? Icon /// public required DateTime LastModified { get; set; } +#if WINDOWS || FILES_WIN32_COMPAT /// - /// Gets or initializes the Windows shell item of the recent item; unused on Linux. + /// Gets or initializes the Windows shell item of the recent item. /// public IShellItem? ShellItem { get; init; } +#endif /// /// Loads thumbnail icon of the recent item. diff --git a/src/Files.App/Data/Models/PinnedFoldersManager.cs b/src/Files.App/Data/Models/PinnedFoldersManager.cs index db181e14bd98..dd4256f21f7b 100644 --- a/src/Files.App/Data/Models/PinnedFoldersManager.cs +++ b/src/Files.App/Data/Models/PinnedFoldersManager.cs @@ -138,12 +138,17 @@ public async Task CreateLocationItemFromPathAsync(string path) private static string? GetLibraryDisplayName(string libraryPath) { +#if !WINDOWS + // Windows libraries (.library-ms) don't exist on Linux + return null; +#else using var storable = WindowsStorable.TryParse(libraryPath); if (storable is null) return null; var displayName = storable.GetDisplayName(SIGDN.SIGDN_NORMALDISPLAY); return string.IsNullOrEmpty(displayName) ? null : displayName; +#endif } private async Task LoadIconForLocationItemAsync(LocationItem locationItem, string path, bool isFolder = true) diff --git a/src/Files.App/Extensions/ShellNewEntryExtensions.cs b/src/Files.App/Extensions/ShellNewEntryExtensions.cs index b29a3badb133..77eca697b6bc 100644 --- a/src/Files.App/Extensions/ShellNewEntryExtensions.cs +++ b/src/Files.App/Extensions/ShellNewEntryExtensions.cs @@ -8,6 +8,7 @@ namespace Files.App.Extensions { public static class ShellNewEntryExtensions { +#if WINDOWS || FILES_WIN32_COMPAT public static async Task> GetNewContextMenuEntries() { var shellEntryList = new List(); @@ -25,6 +26,7 @@ public static async Task> GetNewContextMenuEntries() { return await SafetyExtensions.IgnoreExceptions(() => ShellNewMenuHelper.GetNewContextMenuEntryForType(extension), App.Logger); } +#endif public static async Task> Create(this ShellNewEntry shellEntry, string filePath, IShellPage associatedInstance) { diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index 82dd3eb7800d..9b9b62f4df4f 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -261,6 +261,7 @@ + @@ -291,6 +292,9 @@ the Windows TFM with Platform=x64, which works on Linux) so Win32-touching code gets past declaration binding and the compiler reports the remaining, Uno-specific errors. Nothing here runs on Linux; replace Win32 usage with Files.Platform interfaces, then drop this switch. Prerequisite: dotnet build src/Files.App.CsWin32 -p:EnableWindowsTargeting=true -p:Platform=x64 --> + + $(DefineConstants);FILES_WIN32_COMPAT + ..\Files.App.CsWin32\bin\x64\$(Configuration)\$(WindowsTargetFramework)\Files.App.CsWin32.dll diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 63c6a7632ddc..55e01bf58ffb 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -369,7 +369,11 @@ public static IServiceProvider ConfigureHost(AppModel appModel) #endif .AddSingleton() .AddSingleton() +#if WINDOWS .AddSingleton() +#else + .AddSingleton() +#endif .AddSingleton() .AddSingleton() .AddSingleton() @@ -404,7 +408,11 @@ public static IServiceProvider ConfigureHost(AppModel appModel) #endif .AddSingleton() .AddSingleton() +#if WINDOWS .AddSingleton() +#else + .AddSingleton() +#endif .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs b/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs index 8458104c5de7..1664d6f0ddab 100644 --- a/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs +++ b/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs @@ -58,4 +58,35 @@ internal sealed class DesktopJumpListService : IWindowsJumpListService public Task> GetFoldersAsync() => Task.FromResult>([]); } + + // LINUX-TODO(pickers): open file/folder choosers through the xdg-desktop-portal FileChooser; until then pickers report "cancelled" + internal sealed class DesktopCommonDialogService : ICommonDialogService + { + public bool Open_FileOpenDialog(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, out string filePath, Guid? clientGuid = null) + { + filePath = string.Empty; + return false; + } + + public bool Open_FileSaveDialog(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, out string filePath) + { + filePath = string.Empty; + return false; + } + + public bool Open_NetworkConnectionDialog(nint hWnd, bool hideRestoreConnectionCheckBox = false, bool persistConnectionAtLogon = false, bool readOnlyPath = false, string? remoteNetworkName = null, bool useMostRecentPath = false) => false; + } + + // Linux has no Start Menu pins + internal sealed class DesktopStartMenuService : IStartMenuService + { + [Obsolete("Use IsPinnedAsync instead. This method is used for a workaround in ListedItem class to avoid major refactoring.")] + public bool IsPinned(string itemPath) => false; + + public Task IsPinnedAsync(IStorable storable) => Task.FromResult(false); + + public Task PinAsync(IStorable storable, string? displayName = null) => Task.CompletedTask; + + public Task UnpinAsync(IStorable storable) => Task.CompletedTask; + } } diff --git a/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs b/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs index f110042cd1a6..7d13aa56974f 100644 --- a/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs +++ b/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs @@ -10,8 +10,10 @@ internal sealed partial class PreviewPopupService : ObservableObject, IPreviewPo { if (await QuickLookProvider.Instance.DetectAvailability()) return await Task.FromResult(QuickLookProvider.Instance); +#if WINDOWS if (await SeerProProvider.Instance.DetectAvailability()) return await Task.FromResult(SeerProProvider.Instance); +#endif if (await PowerToysPeekProvider.Instance.DetectAvailability()) return await Task.FromResult(PowerToysPeekProvider.Instance); else diff --git a/src/Files.App/Utils/Cloud/CloudDrivesDetector.cs b/src/Files.App/Utils/Cloud/CloudDrivesDetector.Windows.cs similarity index 100% rename from src/Files.App/Utils/Cloud/CloudDrivesDetector.cs rename to src/Files.App/Utils/Cloud/CloudDrivesDetector.Windows.cs diff --git a/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs b/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs index f04791e043e8..350dead580f1 100644 --- a/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs +++ b/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs @@ -44,9 +44,14 @@ private static IEnumerable EnumerateDetectors() yield return new GoogleDriveCloudDetector(); yield return new DropBoxCloudDetector(); yield return new BoxCloudDetector(); +#if WINDOWS + // Registry and executable icon based yield return new GenericCloudDetector(); +#endif yield return new SynologyDriveCloudDetector(); +#if WINDOWS yield return new OXDriveCloudDetector(); +#endif } } } diff --git a/src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.cs b/src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.Windows.cs similarity index 100% rename from src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.cs rename to src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.Windows.cs diff --git a/src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.cs b/src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.Windows.cs similarity index 100% rename from src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.cs rename to src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.Windows.cs diff --git a/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs b/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs index f1001c78a214..a1f38d43f438 100644 --- a/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs @@ -13,8 +13,9 @@ public interface IStorageItemWithPath public string Path { get; } - // LINUX-TODO(storage): move under #if WINDOWS in P4-Z; desktop consumers (e.g. StorageHelpers.ToStorageItemResult) still read it +#if WINDOWS public IStorageItem? Item { get; } +#endif /// /// Gets the storable for this item, if one has been resolved; otherwise, . diff --git a/src/Files.App/Utils/Storage/Helpers/MtpHelpers.cs b/src/Files.App/Utils/Storage/Helpers/MtpHelpers.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/Helpers/MtpHelpers.cs rename to src/Files.App/Utils/Storage/Helpers/MtpHelpers.Windows.cs diff --git a/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs b/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs index 35339d6428a4..68503787eaab 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs @@ -27,7 +27,9 @@ public sealed record StorableWithPath : IStorageItemWithPath /// public string Name => Storable?.Name ?? IO.Path.GetFileName(IO.Path.TrimEndingDirectorySeparator(Path)); +#if WINDOWS IStorageItem? IStorageItemWithPath.Item => null; +#endif /// /// Initializes a new instance of the record. diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs b/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs index 880213b48dd6..43fdebec4377 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs @@ -11,7 +11,9 @@ public sealed class StorageFileWithPath : IStorageItemWithPath public string Path { get; } public string Name => Item?.Name ?? IO.Path.GetFileName(Path)!; +#if WINDOWS IStorageItem? IStorageItemWithPath.Item => Item; +#endif public BaseStorageFile? Item { get; } public FilesystemItemType ItemType => FilesystemItemType.File; diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs b/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs index 7a93240dc75e..ee8e81b08ac5 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs @@ -11,7 +11,9 @@ public sealed class StorageFolderWithPath : IStorageItemWithPath public string Path { get; } public string Name => Item?.Name ?? IO.Path.GetFileName(Path)!; +#if WINDOWS IStorageItem? IStorageItemWithPath.Item => Item; +#endif public BaseStorageFolder? Item { get; } public FilesystemItemType ItemType => FilesystemItemType.Directory; diff --git a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Storables.cs b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Storables.cs index 98a8b481cb2c..1ec0416f9fb4 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Storables.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Storables.cs @@ -38,5 +38,15 @@ public static async Task> ToStorableResult(this ISto return result; } + /// + /// Gets whether the item is a member of an archive; such items can only be copied out. + /// + public static bool IsArchiveMember(this IStorageItemWithPath item) +#if WINDOWS + => item.Item is ZipStorageFile || item.Item is ZipStorageFolder; +#else + => item.Storable is Files.App.Storage.Archives.ArchiveEntryFile or Files.App.Storage.Archives.ArchiveFolder + || Files.Shared.Helpers.FileExtensionHelpers.IsZipPath(item.Path, includeRoot: false); +#endif } } diff --git a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs index d89a5751f889..00205c1eb116 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs @@ -9,10 +9,7 @@ namespace Files.App.Helpers public static partial class StorageHelpers { #if !WINDOWS - // LINUX-TODO(storage): remove these WinRT entry points as downstream work packages adopt ToStorableResult. - public static async Task ToStorageItem(this IStorageItemWithPath item) - => (await item.ToStorageItemResult()).Result; - + // LINUX-TODO(storage): remove the remaining WinRT entry points (ToStorageItem, FromStorageItem) once their callers use storables. public static async Task ToStorageItem(string path) where TRequested : IStorageItem { var result = await GetStorableAsync(path); @@ -25,22 +22,6 @@ public static partial class StorageHelpers return item is TRequested requested ? requested : default; } - public static async Task> ToStorageItemResult(this IStorageItemWithPath item) - { - var result = await item.ToStorableResult(); - if (!result) - return new(null, result.ErrorCode); - - IStorageItem? legacy = item.Item - ?? (result.Result is IFolder - ? await StorageFileExtensions.OpenLegacyFolderAsync(item.Path) - : await StorageFileExtensions.OpenLegacyFileAsync(item.Path)); - return new(legacy, legacy is null ? FileSystemStatusCode.Generic : FileSystemStatusCode.Success); - } - - public static async Task GetFileSize(this IStorageFile file) - => (long)(await file.GetBasicPropertiesAsync()).Size; - public static IStorageItemWithPath FromPathAndType(string customPath, FilesystemItemType? itemType) => new StorableWithPath(customPath, itemType ?? FilesystemItemType.Directory); @@ -54,9 +35,6 @@ public static bool Exists(string path) => FromPathAndType(!string.IsNullOrEmpty(item?.Path) ? item.Path : customPath ?? throw new InvalidOperationException("A path is required when converting a missing storage item."), item is null ? itemType : item.IsOfType(StorageItemTypes.Folder) ? FilesystemItemType.Directory : FilesystemItemType.File); - - public static FilesystemResult ToType(FilesystemResult result) where T : class - => new(result.Result as T, result.ErrorCode); #endif } } diff --git a/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs b/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs index b66db47004a5..4cfb2f63dc16 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs @@ -10,6 +10,10 @@ internal sealed class StorageSenseHelper { public static async Task OpenStorageSenseAsync(string path) { +#if !WINDOWS + // LINUX-TODO(storage-sense): no system storage cleanup page to open + await Task.CompletedTask; +#else if (!path.StartsWith(Constants.UserEnvironmentPaths.SystemDrivePath, StringComparison.OrdinalIgnoreCase) && ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) { @@ -19,6 +23,7 @@ public static async Task OpenStorageSenseAsync(string path) { await Launcher.LaunchUriAsync(new Uri("ms-settings:storagesense")); } +#endif } } } diff --git a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs index e56090bbb1cc..349e1deb66a4 100644 --- a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs +++ b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs @@ -190,9 +190,7 @@ public async Task DragOverAsync(DragEventArgs? e) // Some applications such as Edge can't raise the drop event by the Move flag (#14008), so we set the Copy flag as well. e.AcceptedOperation = DataPackageOperation.Move | DataPackageOperation.Copy; } - else if (draggedItems.Any(x => - x.Item is ZipStorageFile || - x.Item is ZipStorageFolder) || + else if (draggedItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(workingDirectory)) { e.DragUIOverride.Caption = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), folderName); diff --git a/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs b/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs index f679f6d2f8de..eccb2a35f8e5 100644 --- a/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs +++ b/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs @@ -44,7 +44,12 @@ public override void GetBaseProperties() public async override Task GetSpecialPropertiesAsync() { var libraryPath = Library.GetRequiredPath(); +#if WINDOWS var fileAttributes = Win32Helper.GetFileAttributes(libraryPath); +#else + // Windows libraries (.library-ms) don't exist on Linux + var fileAttributes = default(System.IO.FileAttributes); +#endif ViewModel.IsReadOnly = fileAttributes.HasFlag(System.IO.FileAttributes.ReadOnly); ViewModel.IsHidden = fileAttributes.HasFlag(System.IO.FileAttributes.Hidden); ViewModel.CanCompressContent = false; @@ -141,6 +146,7 @@ private async Task GetLibrarySizeAsync(List storageFolders, C private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { +#if WINDOWS var libraryPath = Library.GetRequiredPath(); switch (e.PropertyName) { @@ -166,6 +172,7 @@ private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.Pro break; } +#endif } } } diff --git a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs index 03ca6d89c6b0..eac3edf97ca3 100644 --- a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs @@ -491,9 +491,7 @@ public async Task PathBoxItem_DragOver(object sender, DragEventArgs e) } // Copy be default when dragging from zip - else if (storageItems.Any(x => - x.Item is ZipStorageFile || - x.Item is ZipStorageFolder) || + else if (storageItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(pathBoxItem.Path)) { e.DragUIOverride.Caption = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), pathBoxItem.Title); @@ -1008,10 +1006,15 @@ public void SaveSearchQueryToList(string searchQuery) private static async Task LaunchApplicationFromPath(string currentInput, string workingDir) { +#if !WINDOWS + // LINUX-TODO(launch): running commands typed into the address bar; the input is reported as an invalid path + return await Task.FromResult(false); +#else var args = CommandLineParser.SplitArguments(currentInput); return await LaunchHelper.LaunchAppAsync( args.FirstOrDefault("").Trim('"'), string.Join(' ', args.Skip(1)), workingDir ); +#endif } public async Task PopulateOmnibarSuggestionsForPathMode() diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index 7eed10430462..3e6be428c906 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -1338,7 +1338,7 @@ private async Task HandleLocationItemDragOverAsync(LocationItem locationItem, It // Some applications such as Edge can't raise the drop event by the Move flag (#14008), so we set the Copy flag as well. operationType = DataPackageOperation.Move | DataPackageOperation.Copy; } - else if (storageItems.Any(x => x.Item is ZipStorageFile || x.Item is ZipStorageFolder) + else if (storageItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(locationItem.Path)) { captionText = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), locationItem.Text); @@ -1494,7 +1494,11 @@ private async Task HandleTagItemDroppedAsync(FileTagItem fileTagItem, ItemDroppe if (!filesTags.Contains(fileTag.Uid)) { filesTags = [.. filesTags, fileTag.Uid]; +#if WINDOWS var fileFRN = await FileTagsHelper.GetFileFRN(item.Item); +#else + var fileFRN = FileTagsHelper.GetFileFRN(path); +#endif dbInstance.SetTags(path, fileFRN, filesTags); await FileTagsHelper.WriteFileTagAsync(path, filesTags); pathToTags[path] = filesTags; diff --git a/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs index b794b05ea41c..1ebb46f187c9 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs @@ -266,7 +266,11 @@ private Task ExecuteMapNetworkDriveCommand() private void ExecuteFormatDriveCommand(WidgetDriveCardItem? item) { +#if !WINDOWS + _ = DriveHelpers.OpenFormatDialogAsync(item?.Path); +#else Win32Helper.OpenFormatDriveDialog(item?.Path ?? string.Empty); +#endif } private void ExecuteOpenPropertiesCommand(WidgetDriveCardItem? item) diff --git a/src/Files.App/Views/Layouts/BaseLayoutPage.cs b/src/Files.App/Views/Layouts/BaseLayoutPage.cs index bda8563f4639..96f7b4c68ef9 100644 --- a/src/Files.App/Views/Layouts/BaseLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseLayoutPage.cs @@ -1077,6 +1077,7 @@ protected virtual void FileList_DragItemsStarting(object sender, DragItemsStarti var sortedItems = SortingHelper.OrderFileList(itemList, folderSettings.DirectorySortOption, folderSettings.DirectorySortDirection, folderSettings.SortDirectoriesAlongsideFiles, folderSettings.SortFilesFirst).ToList(); var orderedItems = sortedItems.SkipWhile(x => x != firstItem).Concat(sortedItems.TakeWhile(x => x != firstItem)).ToList(); +#if WINDOWS var shellItemList = SafetyExtensions.IgnoreExceptions(() => orderedItems.Select(item => new ShellItem(item.GetRequiredPath())).ToArray()); try { @@ -1106,6 +1107,7 @@ protected virtual void FileList_DragItemsStarting(object sender, DragItemsStarti item.Dispose(); } } +#endif // Set can window to front (#13255) MainWindow.Instance.SetCanWindowToFront(false); @@ -1193,7 +1195,7 @@ private async void Item_DragOver(object sender, DragEventArgs e) // Some applications such as Edge can't raise the drop event by the Move flag (#14008), so we set the Copy flag as well. e.AcceptedOperation = DataPackageOperation.Move | DataPackageOperation.Copy; } - else if (draggedItems.Any(x => x.Item is ZipStorageFile || x.Item is ZipStorageFolder) + else if (draggedItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(item.ItemPath!)) { e.DragUIOverride.Caption = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), item.Name); @@ -1482,8 +1484,10 @@ private void FileListItem_Loaded(object sender, RoutedEventArgs e) protected internal void FileListItem_PointerPressed(object sender, PointerRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif if (sender is not SelectorItem selectorItem) return; @@ -1578,23 +1582,29 @@ protected internal void FileListItem_PointerExited(object sender, PointerRoutedE protected void FileListItem_Tapped(object sender, TappedRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif } protected void FileListItem_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif } [DynamicWindowsRuntimeCast(typeof(SelectorItem))] protected void FileListItem_RightTapped(object sender, RightTappedRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif var rightClickedItem = GetItemFromElement(sender); @@ -1768,7 +1778,11 @@ public void CheckRenameDoubleClick(object? clickedItem) tapDebounceTimer?.Stop(); } }, +#if WINDOWS TimeSpan.FromMilliseconds(OperatingSystem.IsWindows() ? PInvoke.GetDoubleClickTime() : 500)); +#else + TimeSpan.FromMilliseconds(500)); +#endif } else {