Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/Files.App/Data/Items/ListedItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,8 @@ public string[] FileTags
// only set the tags if the file tags have been changed
if (fileTagsInitialized)
{
var dbInstance = FileTagsHelper.GetDbInstance();
dbInstance.SetTags(ItemPath, FileFRN, value);
FileTagsHelper.WriteFileTag(ItemPath, value);
// Update the registry index and ADS as one synchronized operation.
_ = FileTagsHelper.SetTagsAndWriteFileTagAsync(ItemPath, FileFRN, value);
}

HasTags = !FileTags.IsEmpty();
Expand Down
40 changes: 25 additions & 15 deletions src/Files.App/Helpers/Application/AppLifecycleHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,23 +111,33 @@ await Task.WhenAll(
// Start non-critical tasks without waiting for them to complete
_ = Task.Run(async () =>
{
await Task.WhenAll(
OptionalTaskAsync(CloudDrivesManager.UpdateDrivesAsync(), generalSettingsService.ShowCloudDrivesSection),
App.LibraryManager.UpdateLibrariesAsync(),
OptionalTaskAsync(WSLDistroManager.UpdateDrivesAsync(), generalSettingsService.ShowWslSection),
OptionalTaskAsync(App.FileTagsManager.UpdateFileTagsAsync(), generalSettingsService.ShowFileTagsSection),
jumpListService.InitializeAsync()
);

//Start the tasks separately to reduce resource contention
await Task.WhenAll(
addItemService.InitializeAsync(),
ContextMenu.WarmUpQueryContextMenuAsync()
);
try
{
await Task.WhenAll(
OptionalTaskAsync(CloudDrivesManager.UpdateDrivesAsync(), generalSettingsService.ShowCloudDrivesSection),
App.LibraryManager.UpdateLibrariesAsync(),
OptionalTaskAsync(WSLDistroManager.UpdateDrivesAsync(), generalSettingsService.ShowWslSection),
OptionalTaskAsync(App.FileTagsManager.UpdateFileTagsAsync(), generalSettingsService.ShowFileTagsSection),
jumpListService.InitializeAsync()
);

// Run these tasks after the preceding initialization to reduce resource contention.
await Task.WhenAll(
addItemService.InitializeAsync(),
ContextMenu.WarmUpQueryContextMenuAsync()
);
}
catch (Exception ex)
{
App.Logger.LogWarning(ex, "Failed to initialize non-critical app services.");
}
finally
{
// Run the tag database scan even if a preceding background task fails.
SafetyExtensions.IgnoreExceptions(FileTagsHelper.UpdateTagsDb, App.Logger);
}
});

FileTagsHelper.UpdateTagsDb();

_ = Task.Run(async () =>
{
// The follwing method invokes UI thread, so we run it in a separate task
Expand Down
5 changes: 2 additions & 3 deletions src/Files.App/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,8 @@ x.tabItem.NavigationParameter.NavigationParameter is PaneNavigationArguments pan
if (fileFRN is not null)
{
var tagUid = tag is not null ? new[] { tag.Uid } : [];
var dbInstance = FileTagsHelper.GetDbInstance();
dbInstance.SetTags(file, fileFRN, tagUid);
FileTagsHelper.WriteFileTag(file, tagUid);
// Update the registry index and ADS as one synchronized operation.
_ = FileTagsHelper.SetTagsAndWriteFileTagAsync(file, fileFRN, tagUid);
}
}
break;
Expand Down
55 changes: 53 additions & 2 deletions src/Files.App/Utils/FileTags/FileTagsDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,30 @@ namespace Files.App.Utils.FileTags
public sealed class FileTagsDatabase
{
private static string? _FileTagsKey;

// Protect the registry path and FRN indices from concurrent updates.
internal static object SyncRoot { get; } = new();

private string? FileTagsKey => _FileTagsKey ??= SafetyExtensions.IgnoreExceptions(() => @$"Software\Files Community\{Package.Current.Id.Name}\v1\FileTags");

internal static void RunSynchronized(Action action)
{
lock (SyncRoot)
action();
}

internal static T RunSynchronized<T>(Func<T> action)
{
lock (SyncRoot)
return action();
}

public void SetTags(string filePath, ulong? frn, string[] tags)
{
RunSynchronized(() => SetTagsCore(filePath, frn, tags));
}

private void SetTagsCore(string filePath, ulong? frn, string[] tags)
{
if (FileTagsKey is null)
return;
Expand Down Expand Up @@ -94,6 +115,11 @@ public void SetTags(string filePath, ulong? frn, string[] tags)
}

public void UpdateTag(string oldFilePath, ulong? frn, string? newFilePath)
{
RunSynchronized(() => UpdateTagCore(oldFilePath, frn, newFilePath));
}

private void UpdateTagCore(string oldFilePath, ulong? frn, string? newFilePath)
{
if (FileTagsKey is null)
return;
Expand Down Expand Up @@ -122,6 +148,11 @@ public void UpdateTag(string oldFilePath, ulong? frn, string? newFilePath)
}

public void UpdateTag(ulong oldFrn, ulong? frn, string? newFilePath)
{
RunSynchronized(() => UpdateTagCore(oldFrn, frn, newFilePath));
}

private void UpdateTagCore(ulong oldFrn, ulong? frn, string? newFilePath)
{
if (FileTagsKey is null)
return;
Expand Down Expand Up @@ -151,10 +182,15 @@ public void UpdateTag(ulong oldFrn, ulong? frn, string? newFilePath)

public string[] GetTags(string? filePath, ulong? frn)
{
return FindTag(filePath, frn)?.Tags ?? [];
return RunSynchronized(() => FindTag(filePath, frn)?.Tags ?? []);
}

public IEnumerable<TaggedFile> GetAll()
{
return RunSynchronized(GetAllCore);
}

private IEnumerable<TaggedFile> GetAllCore()
{
var list = new List<TaggedFile>();

Expand All @@ -174,6 +210,11 @@ public IEnumerable<TaggedFile> GetAll()
}

public IEnumerable<TaggedFile> GetAllUnderPath(string folderPath)
{
return RunSynchronized(() => GetAllUnderPathCore(folderPath));
}

private IEnumerable<TaggedFile> GetAllUnderPathCore(string folderPath)
{
folderPath = folderPath.Replace('/', '\\').TrimStart('\\');
var list = new List<TaggedFile>();
Expand All @@ -194,6 +235,11 @@ public IEnumerable<TaggedFile> GetAllUnderPath(string folderPath)
}

public void Import(string json)
{
RunSynchronized(() => ImportCore(json));
}

private void ImportCore(string json)
{
if (FileTagsKey is null)
return;
Expand All @@ -218,6 +264,11 @@ public void Import(string json)
}

public string Export()
{
return RunSynchronized(ExportCore);
}

private string ExportCore()
{
var list = new List<TaggedFile>();

Expand Down Expand Up @@ -250,4 +301,4 @@ private void IterateKeys(List<TaggedFile> list, string path, int depth)
}
}
}
}
}
127 changes: 82 additions & 45 deletions src/Files.App/Utils/FileTags/FileTagsHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,41 @@ public static class FileTagsHelper

public static string[] ReadFileTag(string filePath)
{
var tagString = Win32Helper.ReadStringFromFile($"{filePath}:files");
return tagString?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? [];
lock (FileTagsDatabase.SyncRoot)
{
var tagString = Win32Helper.ReadStringFromFile($"{filePath}:files");
return tagString?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? [];
}
}

public static async void WriteFileTag(string filePath, string[] tag)
{
bool result;
lock (FileTagsDatabase.SyncRoot)
{
result = WriteFileTagCore(filePath, tag);
}

if (!result)
await ShowTagWriteErrorAsync();
}

public static Task SetTagsAndWriteFileTagAsync(string filePath, ulong? frn, string[] tags)
{
bool result;
lock (FileTagsDatabase.SyncRoot)
{
// Update the registry index and ADS without allowing another tag operation to interleave.
GetDbInstance().SetTags(filePath, frn, tags);
result = WriteFileTagCore(filePath, tags);
}

return result ? Task.CompletedTask : ShowTagWriteErrorAsync();
}

private static bool WriteFileTagCore(string filePath, string[] tag)
{
var result = true;
var isDateOk = Win32Helper.GetFileDateModified(filePath, out var dateModified); // Backup date modified
var isReadOnly = Win32Helper.HasFileAttribute(filePath, IO.FileAttributes.ReadOnly);
if (isReadOnly) // Unset read-only attribute (#7534)
Expand All @@ -37,24 +66,7 @@ public static async void WriteFileTag(string filePath, string[] tag)
}
else if (ReadFileTag(filePath) is not string[] arr || !tag.SequenceEqual(arr))
{
var result = Win32Helper.WriteStringToFile($"{filePath}:files", string.Join(',', tag));
if (result == false)
{
await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () =>
{
ContentDialog dialog = new()
{
Title = Strings.ErrorApplyingTagTitle.GetLocalizedResource(),
Content = Strings.ErrorApplyingTagContent.GetLocalizedResource(),
PrimaryButtonText = "Ok".GetLocalizedResource()
};

if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;

await dialog.TryShowAsync();
});
}
result = Win32Helper.WriteStringToFile($"{filePath}:files", string.Join(',', tag));
}
if (isReadOnly) // Restore read-only attribute (#7534)
{
Expand All @@ -64,47 +76,72 @@ await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () =>
{
Win32Helper.SetFileDateModified(filePath, dateModified); // Restore date modified
}

return result;
}

private static Task ShowTagWriteErrorAsync()
{
return MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () =>
{
// Show the error dialog on the UI thread after releasing the file operation lock.
ContentDialog dialog = new()
{
Title = Strings.ErrorApplyingTagTitle.GetLocalizedResource(),
Content = Strings.ErrorApplyingTagContent.GetLocalizedResource(),
PrimaryButtonText = "Ok".GetLocalizedResource()
};

if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot;

await dialog.TryShowAsync();
});
}

public static void UpdateTagsDb()
{
var dbInstance = GetDbInstance();
foreach (var file in dbInstance.GetAll())
{
var pathFromFrn = Win32Helper.PathFromFileId(file.Frn ?? 0, file.FilePath);
if (pathFromFrn is not null)
lock (FileTagsDatabase.SyncRoot)
{
// Frn is valid, update file path
var tag = ReadFileTag(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal));
if (tag is not null && tag.Any())
// Keep validation and updates for one item atomic with user tag changes.
var pathFromFrn = Win32Helper.PathFromFileId(file.Frn ?? 0, file.FilePath);
if (pathFromFrn is not null)
{
dbInstance.UpdateTag(file.Frn ?? 0, null, pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal));
dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, tag);
// Frn is valid, update file path
var tag = ReadFileTag(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal));
if (tag is not null && tag.Any())
{
dbInstance.UpdateTag(file.Frn ?? 0, null, pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal));
dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, tag);
}
else
{
dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, []);
}
}
else
{
dbInstance.SetTags(pathFromFrn.Replace(@"\\?\", "", StringComparison.Ordinal), file.Frn, []);
}
}
else
{
var tag = ReadFileTag(file.FilePath);
if (tag is not null && tag.Any())
{
if (!SafetyExtensions.IgnoreExceptions(() =>
var tag = ReadFileTag(file.FilePath);
if (tag is not null && tag.Any())
{
var frn = GetFileFRN(file.FilePath);
dbInstance.UpdateTag(file.FilePath, frn, null);
dbInstance.SetTags(file.FilePath, frn, tag);
}, App.Logger))
if (!SafetyExtensions.IgnoreExceptions(() =>
{
var frn = GetFileFRN(file.FilePath);
dbInstance.UpdateTag(file.FilePath, frn, null);
dbInstance.SetTags(file.FilePath, frn, tag);
}, App.Logger))
{
dbInstance.SetTags(file.FilePath, null, []);
}
}
else
{
dbInstance.SetTags(file.FilePath, null, []);
}
}
else
{
dbInstance.SetTags(file.FilePath, null, []);
}
}
}
}
Expand Down Expand Up @@ -152,4 +189,4 @@ public static async Task<bool> RemoveTagsAsync(IEnumerable<ListedItem> items)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1022,12 +1022,14 @@ private static void UpdateFileTagsDb(ShellFileOperations2.ShellFileOpEventArgs e
{
var tag = dbInstance.GetTags(sourcePath, null);

dbInstance.SetTags(destination, FileTagsHelper.GetFileFRN(destination), tag); // copy tag to new files
using var si = new ShellItem(destination);
if (si.IsFolder) // File tag is not copied automatically for folders
{
FileTagsHelper.WriteFileTag(destination, tag);
// Update the registry index and ADS together when copying a folder.
_ = FileTagsHelper.SetTagsAndWriteFileTagAsync(destination, FileTagsHelper.GetFileFRN(destination), tag);
}
else
dbInstance.SetTags(destination, FileTagsHelper.GetFileFRN(destination), tag); // copy tag to new files
}
else
{
Expand Down
5 changes: 2 additions & 3 deletions src/Files.App/ViewModels/UserControls/SidebarViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1438,7 +1438,6 @@ private Task<ReturnResult> HandleDriveItemDroppedAsync(DriveItem driveItem, Item
private async Task HandleTagItemDroppedAsync(FileTagItem fileTagItem, ItemDroppedEventArgs args)
{
var storageItems = await Utils.Storage.FilesystemHelpers.GetDraggedStorageItems(args.DroppedItem);
var dbInstance = FileTagsHelper.GetDbInstance();
var pathToTags = new Dictionary<string, string[]>();
foreach (var item in storageItems.Where(x => !string.IsNullOrEmpty(x.Path)))
{
Expand All @@ -1447,8 +1446,8 @@ private async Task HandleTagItemDroppedAsync(FileTagItem fileTagItem, ItemDroppe
{
filesTags = [.. filesTags, fileTagItem.FileTag.Uid];
var fileFRN = await FileTagsHelper.GetFileFRN(item.Item);
dbInstance.SetTags(item.Path, fileFRN, filesTags);
FileTagsHelper.WriteFileTag(item.Path, filesTags);
// Update the registry index and ADS as one synchronized operation.
_ = FileTagsHelper.SetTagsAndWriteFileTagAsync(item.Path, fileFRN, filesTags);
pathToTags[item.Path] = filesTags;
}
}
Expand Down