diff --git a/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs new file mode 100644 index 000000000000..3a8917f27f9e --- /dev/null +++ b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2024 Files Community +// Licensed under the MIT License. See the LICENSE. + +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation.Metadata; + +namespace Files.App.Actions +{ + internal sealed class CreateAlternateDataStreamAction : BaseUIAction, IAction + { + private readonly IContentPageContext context; + + private static readonly IFoldersSettingsService FoldersSettingsService = Ioc.Default.GetRequiredService(); + private static readonly IApplicationSettingsService ApplicationSettingsService = Ioc.Default.GetRequiredService(); + + public string Label + => Strings.CreateAlternateDataStream.GetLocalizedResource(); + + public string Description + => Strings.CreateAlternateDataStreamDescription.GetLocalizedResource(); + + public override bool IsExecutable => + context.HasSelection && + context.CanCreateItem && + UIHelpers.CanShowDialog; + + public CreateAlternateDataStreamAction() + { + context = Ioc.Default.GetRequiredService(); + + context.PropertyChanged += Context_PropertyChanged; + } + + public async Task ExecuteAsync(object? parameter = null) + { + var nameDialog = DynamicDialogFactory.GetFor_CreateAlternateDataStreamDialog(); + await nameDialog.TryShowAsync(); + + if (nameDialog.DynamicResult != DynamicDialogResult.Primary) + return; + + var userInput = nameDialog.ViewModel.AdditionalData as string; + await Task.WhenAll(context.SelectedItems.Select(async selectedItem => + { + var isDateOk = Win32Helper.GetFileDateModified(selectedItem.ItemPath, out var dateModified); + var isReadOnly = Win32Helper.HasFileAttribute(selectedItem.ItemPath, System.IO.FileAttributes.ReadOnly); + + // Unset read-only attribute (#7534) + if (isReadOnly) + Win32Helper.UnsetFileAttribute(selectedItem.ItemPath, System.IO.FileAttributes.ReadOnly); + + if (!Win32Helper.WriteStringToFile($"{selectedItem.ItemPath}:{userInput}", "")) + { + var dialog = new ContentDialog + { + Title = Strings.ErrorCreatingDataStreamTitle.GetLocalizedResource(), + Content = Strings.ErrorCreatingDataStreamDescription.GetLocalizedResource(), + PrimaryButtonText = "Ok".GetLocalizedResource() + }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + await dialog.TryShowAsync(); + } + + // Restore read-only attribute (#7534) + if (isReadOnly) + Win32Helper.SetFileAttribute(selectedItem.ItemPath, System.IO.FileAttributes.ReadOnly); + + // Restore date modified + if (isDateOk) + Win32Helper.SetFileDateModified(selectedItem.ItemPath, dateModified); + })); + + if (context.ShellPage is null) + return; + + if (FoldersSettingsService.AreAlternateStreamsVisible) + await context.ShellPage.Refresh_Click(); + else if (ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt) + { + var dialog = new ContentDialog + { + Title = Strings.DataStreamsAreHiddenTitle.GetLocalizedResource(), + Content = Strings.DataStreamsAreHiddenDescription.GetLocalizedResource(), + PrimaryButtonText = Strings.Yes.GetLocalizedResource(), + SecondaryButtonText = Strings.DontShowAgain.GetLocalizedResource() + }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + var result = await dialog.TryShowAsync(); + if (result == ContentDialogResult.Primary) + { + FoldersSettingsService.AreAlternateStreamsVisible = true; + await context.ShellPage.Refresh_Click(); + } + else + ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt = false; + } + } + + private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(IContentPageContext.HasSelection): + case nameof(IContentPageContext.CanCreateItem): + OnPropertyChanged(nameof(IsExecutable)); + break; + } + } + } +} diff --git a/src/Files.App/Data/Commands/Manager/CommandCodes.cs b/src/Files.App/Data/Commands/Manager/CommandCodes.cs index 42858fa1303a..fb66e73f8b44 100644 --- a/src/Files.App/Data/Commands/Manager/CommandCodes.cs +++ b/src/Files.App/Data/Commands/Manager/CommandCodes.cs @@ -42,6 +42,7 @@ public enum CommandCodes CreateFolder, CreateFolderWithSelection, AddItem, + CreateAlternateDataStream, CreateShortcut, CreateShortcutFromDialog, EmptyRecycleBin, diff --git a/src/Files.App/Data/Commands/Manager/CommandManager.cs b/src/Files.App/Data/Commands/Manager/CommandManager.cs index aba8781e33ca..3d3f1a1f250c 100644 --- a/src/Files.App/Data/Commands/Manager/CommandManager.cs +++ b/src/Files.App/Data/Commands/Manager/CommandManager.cs @@ -67,6 +67,7 @@ public IRichCommand this[HotKey hotKey] public IRichCommand RestoreAllRecycleBin => commands[CommandCodes.RestoreAllRecycleBin]; public IRichCommand RefreshItems => commands[CommandCodes.RefreshItems]; public IRichCommand Rename => commands[CommandCodes.Rename]; + public IRichCommand CreateAlternateDataStream => commands[CommandCodes.CreateAlternateDataStream]; public IRichCommand CreateShortcut => commands[CommandCodes.CreateShortcut]; public IRichCommand CreateShortcutFromDialog => commands[CommandCodes.CreateShortcutFromDialog]; public IRichCommand CreateFolder => commands[CommandCodes.CreateFolder]; @@ -263,6 +264,7 @@ public IEnumerator GetEnumerator() => [CommandCodes.RestoreAllRecycleBin] = new RestoreAllRecycleBinAction(), [CommandCodes.RefreshItems] = new RefreshItemsAction(), [CommandCodes.Rename] = new RenameAction(), + [CommandCodes.CreateAlternateDataStream] = new CreateAlternateDataStreamAction(), [CommandCodes.CreateShortcut] = new CreateShortcutAction(), [CommandCodes.CreateShortcutFromDialog] = new CreateShortcutFromDialogAction(), [CommandCodes.CreateFolder] = new CreateFolderAction(), diff --git a/src/Files.App/Data/Commands/Manager/ICommandManager.cs b/src/Files.App/Data/Commands/Manager/ICommandManager.cs index 03735eed1d38..bd788085d662 100644 --- a/src/Files.App/Data/Commands/Manager/ICommandManager.cs +++ b/src/Files.App/Data/Commands/Manager/ICommandManager.cs @@ -51,6 +51,7 @@ public interface ICommandManager : IEnumerable IRichCommand CreateFolder { get; } IRichCommand CreateFolderWithSelection { get; } IRichCommand AddItem { get; } + IRichCommand CreateAlternateDataStream { get; } IRichCommand CreateShortcut { get; } IRichCommand CreateShortcutFromDialog { get; } IRichCommand EmptyRecycleBin { get; } diff --git a/src/Files.App/Data/Contracts/IApplicationSettingsService.cs b/src/Files.App/Data/Contracts/IApplicationSettingsService.cs index a2bda48e27ca..062dc4de6278 100644 --- a/src/Files.App/Data/Contracts/IApplicationSettingsService.cs +++ b/src/Files.App/Data/Contracts/IApplicationSettingsService.cs @@ -15,5 +15,10 @@ public interface IApplicationSettingsService : IBaseSettingsService /// bool ShowRunningAsAdminPrompt { get; set; } + /// + /// Gets or sets a value indicating whether or not to display a prompt when creating an alternate data stream. + /// + bool ShowDataStreamsAreHiddenPrompt { get; set; } + } } diff --git a/src/Files.App/Data/Contracts/IGeneralSettingsService.cs b/src/Files.App/Data/Contracts/IGeneralSettingsService.cs index 48221e6454f8..16de6102398f 100644 --- a/src/Files.App/Data/Contracts/IGeneralSettingsService.cs +++ b/src/Files.App/Data/Contracts/IGeneralSettingsService.cs @@ -210,6 +210,11 @@ public interface IGeneralSettingsService : IBaseSettingsService, INotifyProperty /// bool ShowCopyPath { get; set; } + /// + /// Gets or sets a value indicating whether or not to show the option to create alternate data stream. + /// + bool ShowCreateAlternateDataStream { get; set; } + /// /// Gets or sets a value indicating whether or not to show the option to create a shortcut. /// diff --git a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs index 67cf54c347bd..6058791f568f 100644 --- a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs +++ b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs @@ -477,6 +477,11 @@ public static List GetBaseItemMenuItems( && (!selectedItems.FirstOrDefault()?.IsShortcut ?? false) && !currentInstanceViewModel.IsPageTypeRecycleBin, }.Build(), + new ContextMenuFlyoutItemViewModelBuilder(Commands.CreateAlternateDataStream) + { + IsVisible = UserSettingsService.GeneralSettingsService.ShowCreateAlternateDataStream && + Commands.CreateAlternateDataStream.IsExecutable, + }.Build(), new ContextMenuFlyoutItemViewModelBuilder(Commands.Rename) { IsPrimary = true, diff --git a/src/Files.App/Helpers/Dialog/DialogDisplayHelper.cs b/src/Files.App/Helpers/Dialog/DialogDisplayHelper.cs index 48a8f7e91bfa..bc307837a9c2 100644 --- a/src/Files.App/Helpers/Dialog/DialogDisplayHelper.cs +++ b/src/Files.App/Helpers/Dialog/DialogDisplayHelper.cs @@ -2,10 +2,7 @@ // Licensed under the MIT License. See the LICENSE. using Files.App.Dialogs; -using Files.App.ViewModels.Dialogs; using Microsoft.UI.Xaml.Controls; -using System; -using System.Threading.Tasks; namespace Files.App.Helpers { diff --git a/src/Files.App/Helpers/Dialog/DynamicDialogFactory.cs b/src/Files.App/Helpers/Dialog/DynamicDialogFactory.cs index f7a8a20e3442..18e4446e8031 100644 --- a/src/Files.App/Helpers/Dialog/DynamicDialogFactory.cs +++ b/src/Files.App/Helpers/Dialog/DynamicDialogFactory.cs @@ -2,14 +2,11 @@ // Licensed under the MIT License. See the LICENSE. using Files.App.Dialogs; -using Files.App.ViewModels.Dialogs; using Microsoft.UI; -using Microsoft.UI.Text; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Media; -using Windows.ApplicationModel.DataTransfer; using Windows.System; namespace Files.App.Helpers @@ -333,5 +330,79 @@ public static DynamicDialog GetFor_RenameRequiresHigherPermissions(string path) return dialog; } + + public static DynamicDialog GetFor_CreateAlternateDataStreamDialog() + { + DynamicDialog? dialog = null; + TextBox inputText = new() + { + PlaceholderText = Strings.EnterDataStreamName.GetLocalizedResource() + }; + + TeachingTip warning = new() + { + Title = Strings.InvalidFilename_Text.GetLocalizedResource(), + PreferredPlacement = TeachingTipPlacementMode.Bottom, + DataContext = new CreateItemDialogViewModel(), + }; + + warning.SetBinding(TeachingTip.TargetProperty, new Binding() + { + Source = inputText + }); + warning.SetBinding(TeachingTip.IsOpenProperty, new Binding() + { + Mode = BindingMode.OneWay, + Path = new PropertyPath("IsNameInvalid") + }); + + inputText.Resources.Add("InvalidNameWarningTip", warning); + + inputText.TextChanged += (textBox, args) => + { + var isInputValid = FilesystemHelpers.IsValidForFilename(inputText.Text); + ((CreateItemDialogViewModel)warning.DataContext).IsNameInvalid = !string.IsNullOrEmpty(inputText.Text) && !isInputValid; + dialog!.ViewModel.DynamicButtonsEnabled = isInputValid + ? DynamicDialogButtons.Primary | DynamicDialogButtons.Cancel + : DynamicDialogButtons.Cancel; + if (isInputValid) + dialog.ViewModel.AdditionalData = inputText.Text; + }; + + inputText.Loaded += (s, e) => + { + // dispatching to the ui thread fixes an issue where the primary dialog button would steal focus + _ = inputText.DispatcherQueue.EnqueueOrInvokeAsync(() => inputText.Focus(FocusState.Programmatic)); + }; + + dialog = new DynamicDialog(new DynamicDialogViewModel() + { + TitleText = string.Format(Strings.CreateAlternateDataStream.GetLocalizedResource()), + SubtitleText = null, + DisplayControl = new Grid() + { + MinWidth = 300d, + Children = + { + inputText + } + }, + PrimaryButtonAction = (vm, e) => + { + vm.HideDialog(); + }, + PrimaryButtonText = Strings.Create.GetLocalizedResource(), + CloseButtonText = Strings.Cancel.GetLocalizedResource(), + DynamicButtonsEnabled = DynamicDialogButtons.Cancel, + DynamicButtons = DynamicDialogButtons.Primary | DynamicDialogButtons.Cancel + }); + + dialog.Closing += (s, e) => + { + warning.IsOpen = false; + }; + + return dialog; + } } } diff --git a/src/Files.App/Services/Settings/ApplicationSettingsService.cs b/src/Files.App/Services/Settings/ApplicationSettingsService.cs index 44fbf60eb930..a0ec96f8b9f2 100644 --- a/src/Files.App/Services/Settings/ApplicationSettingsService.cs +++ b/src/Files.App/Services/Settings/ApplicationSettingsService.cs @@ -19,6 +19,12 @@ public bool ShowRunningAsAdminPrompt get => Get(true); set => Set(value); } + + public bool ShowDataStreamsAreHiddenPrompt + { + get => Get(true); + set => Set(value); + } public ApplicationSettingsService(ISettingsSharingContext settingsSharingContext) { diff --git a/src/Files.App/Services/Settings/GeneralSettingsService.cs b/src/Files.App/Services/Settings/GeneralSettingsService.cs index 8000e8532ccf..7641b1ec4fdd 100644 --- a/src/Files.App/Services/Settings/GeneralSettingsService.cs +++ b/src/Files.App/Services/Settings/GeneralSettingsService.cs @@ -281,6 +281,12 @@ public bool ShowCreateFolderWithSelection set => Set(value); } + public bool ShowCreateAlternateDataStream + { + get => Get(false); + set => Set(value); + } + public bool ShowCreateShortcut { get => Get(true); diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index 07de3cb82ab2..65d121ad30e0 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -3971,4 +3971,28 @@ Bulk rename + + Show option to create alternate data stream + + + Create alternate data stream + + + Create alternate data stream for the selected item(s) + + + Enter data stream name + + + There was an error creating the alternate data stream + + + Please note that alternate data streams only work on drives formatted as NTFS. + + + Alternate data streams are currently hidden + + + Would you like to display alternate data streams? You can modify this setting anytime from the files and folders settings page. + \ No newline at end of file diff --git a/src/Files.App/ViewModels/Settings/GeneralViewModel.cs b/src/Files.App/ViewModels/Settings/GeneralViewModel.cs index 83f7ae1e3d2b..004d51fa6a09 100644 --- a/src/Files.App/ViewModels/Settings/GeneralViewModel.cs +++ b/src/Files.App/ViewModels/Settings/GeneralViewModel.cs @@ -279,6 +279,20 @@ public bool ShowCopyPath } } + public bool ShowCreateAlternateDataStream + { + get => UserSettingsService.GeneralSettingsService.ShowCreateAlternateDataStream; + set + { + if (value != UserSettingsService.GeneralSettingsService.ShowCreateAlternateDataStream) + { + UserSettingsService.GeneralSettingsService.ShowCreateAlternateDataStream = value; + + OnPropertyChanged(); + } + } + } + public bool ShowCreateShortcut { get => UserSettingsService.GeneralSettingsService.ShowCreateShortcut; diff --git a/src/Files.App/Views/Settings/GeneralPage.xaml b/src/Files.App/Views/Settings/GeneralPage.xaml index e1b76092f55d..490a146e8ef3 100644 --- a/src/Files.App/Views/Settings/GeneralPage.xaml +++ b/src/Files.App/Views/Settings/GeneralPage.xaml @@ -304,6 +304,14 @@ Style="{StaticResource RightAlignedToggleSwitchStyle}" /> + + + + +