diff --git a/src/Files.App/Data/Items/ListedItem.cs b/src/Files.App/Data/Items/ListedItem.cs index d3a94b71d753..3404b4e00f44 100644 --- a/src/Files.App/Data/Items/ListedItem.cs +++ b/src/Files.App/Data/Items/ListedItem.cs @@ -225,7 +225,11 @@ public BitmapImage ShieldIcon public string ItemPath { get => itemPath; - set => SetProperty(ref itemPath, value); + set + { + if (SetProperty(ref itemPath, value)) + OnPropertyChanged(nameof(Name)); + } } private string itemNameRaw; diff --git a/src/Files.App/Helpers/Win32/Win32PInvoke.Consts.cs b/src/Files.App/Helpers/Win32/Win32PInvoke.Consts.cs index 39508efa6046..a6ae604c6be6 100644 --- a/src/Files.App/Helpers/Win32/Win32PInvoke.Consts.cs +++ b/src/Files.App/Helpers/Win32/Win32PInvoke.Consts.cs @@ -16,6 +16,9 @@ public static partial class Win32PInvoke public const int FILE_NOTIFY_CHANGE_LAST_ACCESS = 32; public const int FILE_NOTIFY_CHANGE_CREATION = 64; public const int FILE_NOTIFY_CHANGE_SECURITY = 256; + public const int FILE_NOTIFY_CHANGE_STREAM_NAME = 512; + public const int FILE_NOTIFY_CHANGE_STREAM_SIZE = 1024; + public const int FILE_NOTIFY_CHANGE_STREAM_WRITE = 2048; public const int INVALID_HANDLE_VALUE = -1; public const int FILE_SHARE_READ = 0x00000001; diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index 2648e64c08c3..0e03484e9646 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -2383,6 +2383,9 @@ private void WatchForDirectoryChanges(string path, CloudDriveSyncStatus syncStat if (hasSyncStatus) notifyFilters |= FILE_NOTIFY_CHANGE_ATTRIBUTES; + if (UserSettingsService.FoldersSettingsService.AreAlternateStreamsVisible) + notifyFilters |= FILE_NOTIFY_CHANGE_STREAM_NAME | FILE_NOTIFY_CHANGE_STREAM_SIZE | FILE_NOTIFY_CHANGE_STREAM_WRITE; + var overlapped = new OVERLAPPED(); using var eventHandle = PInvoke.CreateEvent(null, false, false, null); overlapped.hEvent = eventHandle.DangerousGetHandle(); @@ -2593,6 +2596,12 @@ private async Task ProcessOperationQueueAsync(CancellationToken cancellationToke const uint FILE_ACTION_MODIFIED = 0x00000003; const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004; const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005; + const uint FILE_ACTION_ADDED_STREAM = 0x00000006; + const uint FILE_ACTION_REMOVED_STREAM = 0x00000007; + const uint FILE_ACTION_MODIFIED_STREAM = 0x00000008; + + // Not a system action; requeued by the stream cases below + const uint FILE_ACTION_RECHECK_STREAM = 0xFFFFFFFF; const int UPDATE_BATCH_SIZE = 32; var sampler = new IntervalSampler(200); @@ -2644,6 +2653,11 @@ async Task HandleChangesOccurredAsync() case FILE_ACTION_MODIFIED: if (!updateQueue.Contains(operation.FileName)) updateQueue.Enqueue(operation.FileName); + + // Some filesystems report stream deletion only as a modification + // of the host file, without any FILE_ACTION_*_STREAM action + if (await SyncAlternateStreamsForFileAsync(operation.FileName)) + anyEdits = true; break; case FILE_ACTION_REMOVED: @@ -2662,13 +2676,20 @@ async Task HandleChangesOccurredAsync() { operationQueue.TryDequeue(out _); var newPath = nextOp.FileName; + var oldPath = operation.FileName; await dispatcherQueue.EnqueueOrInvokeAsync(() => { renamed.ItemPath = newPath; renamed.ItemNameRaw = Path.GetFileName(newPath); if (renamed.PrimaryItemAttribute == StorageItemTypes.File) renamed.FileExtension = Path.GetExtension(newPath); + + foreach (var adsItem in filesAndFolders.ToList().OfType().Where(x => x.MainStreamPath.Equals(oldPath, StringComparison.OrdinalIgnoreCase))) + adsItem.ItemPath = $"{newPath}:{adsItem.ItemNameRaw}"; }); + + if (await SyncAlternateStreamsForFileAsync(newPath)) + anyEdits = true; } else { @@ -2677,6 +2698,24 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => anyEdits = true; } break; + + case FILE_ACTION_ADDED_STREAM: + case FILE_ACTION_REMOVED_STREAM: + case FILE_ACTION_MODIFIED_STREAM: + case FILE_ACTION_RECHECK_STREAM: + if (await SyncAlternateStreamsAsync(operation.FileName)) + anyEdits = true; + else if (operation.Action != FILE_ACTION_RECHECK_STREAM) + { + // The notification can arrive before the operation is visible on disk + var streamPath = operation.FileName; + _ = Task.Delay(500).ContinueWith(_ => + { + operationQueue.Enqueue((FILE_ACTION_RECHECK_STREAM, streamPath)); + operationEvent.Set(); + }); + } + break; } } catch (Exception ex) @@ -2945,7 +2984,7 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => if (UserSettingsService.FoldersSettingsService.AreAlternateStreamsVisible) { // Main file is removed, remove connected ADS - foreach (var adsItem in filesAndFolders.ToList().Where(x => x is AlternateStreamItem ads && ads.MainStreamPath == matchingItem.ItemPath)) + foreach (var adsItem in filesAndFolders.ToList().Where(x => x is AlternateStreamItem ads && ads.MainStreamPath.Equals(matchingItem.ItemPath, StringComparison.OrdinalIgnoreCase))) filesAndFolders.Remove(adsItem); } @@ -2960,6 +2999,67 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => return null; } + private Task SyncAlternateStreamsAsync(string streamPath) + { + // Stream notifications name the stream as ":"; a colon not past the + // last separator is the drive colon, i.e. an event for the unnamed data stream + var separatorIndex = streamPath.LastIndexOf(':'); + if (separatorIndex <= streamPath.LastIndexOf('\\')) + return Task.FromResult(false); + + return SyncAlternateStreamsForFileAsync(streamPath.Substring(0, separatorIndex)); + } + + private async Task SyncAlternateStreamsForFileAsync(string mainStreamPath) + { + if (!UserSettingsService.FoldersSettingsService.AreAlternateStreamsVisible) + return false; + + try + { + await enumFolderSemaphore.WaitAsync(semaphoreCTS.Token); + } + catch (OperationCanceledException) + { + return false; + } + + try + { + var items = filesAndFolders.ToList(); + var mainItem = items.FirstOrDefault(x => x is not AlternateStreamItem && x.ItemPath.Equals(mainStreamPath, StringComparison.OrdinalIgnoreCase)); + if (mainItem is null) + return false; + + var onDisk = Win32Helper.GetAlternateStreams(mainStreamPath) + .Select(ads => Win32StorageEnumerator.GetAlternateStream(ads, mainItem)) + .ToDictionary(x => x.ItemPath, StringComparer.OrdinalIgnoreCase); + + var anyChanges = false; + + foreach (var adsItem in items.OfType().Where(x => x.MainStreamPath.Equals(mainStreamPath, StringComparison.OrdinalIgnoreCase))) + { + if (!onDisk.Remove(adsItem.ItemPath)) + { + filesAndFolders.Remove(adsItem); + anyChanges = true; + } + } + + foreach (var adsItem in onDisk.Values) + { + filesAndFolders.Add(adsItem); + anyChanges = true; + } + + return anyChanges; + } + finally + { + enumFolderSemaphore.Release(); + } + } + public async Task AddSearchResultsToCollectionAsync(ObservableCollection searchItems, string currentSearchPath) { filesAndFolders.Clear();