sources/Google.Solutions.Mvvm/Controls/FileBrowser.cs (604 lines of code) (raw):

// // Copyright 2022 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // using Google.Solutions.Common.IO; using Google.Solutions.Common.Linq; using Google.Solutions.Common.Util; using Google.Solutions.Mvvm.Binding; using Google.Solutions.Mvvm.Format; using Google.Solutions.Mvvm.Shell; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; #pragma warning disable VSTHRD100 // Avoid async void methods namespace Google.Solutions.Mvvm.Controls { public partial class FileBrowser : DpiAwareUserControl { private readonly FileTypeCache fileTypeCache = new FileTypeCache(); private IFileSystem? fileSystem = null; private readonly Dictionary<IFileItem, ObservableCollection<IFileItem>> listFilesCache = new Dictionary<IFileItem, ObservableCollection<IFileItem>>(); internal DirectoryTreeView Directories => this.directoryTree; internal FileListView Files => this.fileList; private Breadcrumb? root; private Breadcrumb? navigationState; private IList<IFileItem>? currentDirectoryContents; internal ITaskDialog TaskDialog { get; set; } = new TaskDialog(); internal IOperationProgressDialog ProgressDialog { get; set; } = new OperationProgressDialog(); /// <summary> /// Buffer size for stream copy operations. /// </summary> public int StreamCopyBufferSize { get; set; } = StreamExtensions.DefaultBufferSize; //--------------------------------------------------------------------- // Privates. //--------------------------------------------------------------------- private int GetImageIndex(FileType fileType) { Debug.Assert(!this.InvokeRequired, "Running on UI thread"); if (this.IsDisposed) { // // Don't touch the image list if we're already disposing. // return 0; } // // NB. fileIconsList.Images.IndexOfKey doesn't work reliably in // high-DPI mode, so we use .Images.Keys.IndexOf instead. // var imageIndex = this.fileIconsList.Images.Keys.IndexOf(fileType.TypeName); if (imageIndex == -1) { // // Image not added to list yet. // this.fileIconsList.Images.Add(fileType.TypeName, fileType.FileIcon); imageIndex = this.fileIconsList.Images.Keys.IndexOf(fileType.TypeName); Debug.Assert(imageIndex != -1); } return imageIndex; } public FileBrowser() { InitializeComponent(); this.Disposed += (s, e) => { this.fileTypeCache.Dispose(); this.fileSystem?.Dispose(); }; } //--------------------------------------------------------------------- // Events. //--------------------------------------------------------------------- public event EventHandler<ExceptionEventArgs>? NavigationFailed; public event EventHandler<ExceptionEventArgs>? FileCopyFailed; public event EventHandler? CurrentDirectoryChanged; public event EventHandler? SelectedFilesChanged; protected void OnNavigationFailed(Exception e) { this.NavigationFailed?.Invoke(this, new ExceptionEventArgs(e)); } protected void OnFileCopyFailed(Exception e) { this.FileCopyFailed?.Invoke(this, new ExceptionEventArgs(e)); } protected void OnCurrentDirectoryChanged() { this.CurrentDirectoryChanged?.Invoke(this, EventArgs.Empty); } protected void OnSelectedFilesChanged() { this.SelectedFilesChanged?.Invoke(this, EventArgs.Empty); } //--------------------------------------------------------------------- // Selection properties. //--------------------------------------------------------------------- public IEnumerable<IFileItem> SelectedFiles { get => this.fileList.SelectedModelItems; set => this.fileList.SelectedModelItems = value; } /// <summary> /// Directory that is currently being viewed. /// </summary> public IFileItem? CurrentDirectory { get => this.navigationState?.Directory; } public string? CurrentPath { get { if (this.navigationState == null) { return null; } else { return string.Join( this.directoryTree.PathSeparator, this.navigationState.Path); } } } //--------------------------------------------------------------------- // Data Binding. //--------------------------------------------------------------------- private async Task<ObservableCollection<IFileItem>> ListFilesAsync(IFileItem directory) { Debug.Assert(!this.InvokeRequired, "Running on UI thread"); Debug.Assert(this.fileSystem != null); if (this.fileSystem == null) { throw new InvalidOperationException("Control is not bound"); } // // NB. Both controls must use the same file items so that expansion // tracking works correctly. Instead of binding each control individually, // we therefore put a cache in between. // if (!this.listFilesCache.TryGetValue(directory, out var children)) { children = await this.fileSystem .ListFilesAsync(directory) .ConfigureAwait(true); Debug.Assert(children != null); this.listFilesCache[directory] = children!; } return children!; } private async Task RebindToNavigationStateAsync() { Debug.Assert(this.navigationState != null); var directory = this.navigationState!.Directory; Debug.Assert(!directory.Type.IsFile); // // Update list view. // var files = await ListFilesAsync(directory).ConfigureAwait(true); this.fileList.BindCollection(files); // // Update tree view. // this.directoryTree.SelectedNode = this.navigationState.TreeNode; this.currentDirectoryContents = files; OnCurrentDirectoryChanged(); } public void Bind(IFileSystem fileSystem, IBindingContext bindingContext) { fileSystem.ExpectNotNull(nameof(fileSystem)); if (this.fileSystem != null) { throw new InvalidOperationException("Control is already bound"); } if (fileSystem.Root.Type.IsFile) { throw new ArgumentException("The root item must be a directory"); } this.fileSystem = fileSystem; // // The first icon in the image list is used for the "Loading..." // dummy node in the directory tree. Use the Find icon for that. // this.fileIconsList.Images.Add( StockIcons.GetIcon(StockIcons.IconId.Find, StockIcons.IconSize.Small)); // // Bind directory tree. // this.directoryTree.BindIsLeaf(i => i.Type.IsFile); this.directoryTree.BindText(i => i.Name); this.directoryTree.BindIsExpanded(i => i.IsExpanded); this.directoryTree.BindChildren(async item => { var files = await ListFilesAsync(item).ConfigureAwait(true); return new FilteredObservableCollection<IFileItem>(files) { Predicate = f => !f.Type.IsFile }; }); this.directoryTree.BindImageIndex(i => GetImageIndex(i.Type), true); this.directoryTree.BindSelectedImageIndex(i => GetImageIndex(i.Type), true); this.directoryTree.Bind(this.fileSystem.Root, bindingContext); this.root = new Breadcrumb( null, this.directoryTree.Nodes.Cast<DirectoryTreeView.Node>().First()); this.navigationState = this.root; // // Bind file list. // this.fileList.BindImageIndex(i => GetImageIndex(i.Type)); this.fileList.BindColumn(0, i => i.Name); this.fileList.BindColumn(1, i => i.LastModified.ToString()); this.fileList.BindColumn(2, i => i.Type.TypeName); this.fileList.BindColumn(3, i => i.Access); this.fileList.BindColumn(4, i => ByteSizeFormatter.Format(i.Size)); this.directoryTree.LoadingChildrenFailed += (s, args) => OnNavigationFailed(args.Exception); this.fileList.ItemSelectionChanged += (s, args) => OnSelectedFilesChanged(); } //--------------------------------------------------------------------- // Event handlers - navigation. //--------------------------------------------------------------------- private async void directoryTree_SelectedModelNodeChanged(object sender, EventArgs args) { if (this.directoryTree.SelectedNode is DirectoryTreeView.Node node && node != null) { // // The node could be anywhere, so build new breadcrumb path. // Breadcrumb CreatePathTo(DirectoryTreeView.Node n) { return new Breadcrumb( n.Parent == null ? null : CreatePathTo((DirectoryTreeView.Node)n.Parent), n); } this.navigationState = CreatePathTo(node); try { await RebindToNavigationStateAsync().ConfigureAwait(true); } catch (Exception e) { OnNavigationFailed(e); } } } private async void fileList_DoubleClick(object sender, EventArgs args) { if (this.root == null || this.CurrentDirectory == null) { throw new InvalidOperationException("Control is not bound"); } if (this.fileList.SelectedModelItem == null || this.fileList.SelectedModelItem.Type.IsFile) { return; } try { await NavigateDownAsync(this.fileList.SelectedModelItem.Name) .ConfigureAwait(true); this.CurrentDirectory.IsExpanded = true; } catch (Exception e) { OnNavigationFailed(e); } } private async void fileList_KeyDown(object sender, KeyEventArgs args) { try { if (args.KeyCode == Keys.Enter && this.fileList.SelectedModelItem is var item && item != null && !item.Type.IsFile) { // // Go down one level, same as double-click. // fileList_DoubleClick(sender, EventArgs.Empty); args.Handled = true; } else if (args.KeyCode == Keys.C && args.Control) { // // Copy files. // copyToolStripMenuItem_Click(sender, EventArgs.Empty); args.Handled = true; } else if (args.KeyCode == Keys.V && args.Control) { // // Paste files. // pasteToolStripMenuItem_Click(sender, EventArgs.Empty); args.Handled = true; } else if (args.KeyCode == Keys.Up && args.Alt) { // // Go up one level. // await NavigateUpAsync(); args.Handled = true; } else if (args.KeyCode == Keys.F5) { await RefreshAsync(); args.Handled = true; } } catch (Exception e) { OnNavigationFailed(e); } } private async void refreshToolStripMenuItem_Click(object sender, EventArgs args) { try { await RefreshAsync().ConfigureAwait(true); } catch (Exception e) { OnNavigationFailed(e); } } //--------------------------------------------------------------------- // Event handlers - copy/drag. //--------------------------------------------------------------------- private void copyToolStripMenuItem_Click(object sender, EventArgs args) { try { Clipboard.SetDataObject( CopySelectedFiles(), false); } catch (Exception e) { OnFileCopyFailed(e); } } private void fileList_MouseMove(object sender, MouseEventArgs args) { if (args.Button != MouseButtons.Left) { return; } try { var dataObject = CopySelectedFiles(); if (dataObject.Files.Any()) { // // NB. Only begin a drag operation when there's actually // a file in the data object, otherwise the drop target // will indicate that there's something to drop, even // though there isn't. // DoDragDrop(dataObject, DragDropEffects.Copy); } } catch (Exception e) { OnFileCopyFailed(e); } } //--------------------------------------------------------------------- // Event handlers - paste/drop. //--------------------------------------------------------------------- private async void pasteToolStripMenuItem_Click(object sender, EventArgs args) { try { await PasteFilesAsync(Clipboard.GetDataObject()).ConfigureAwait(true); } catch (Exception e) { OnFileCopyFailed(e); } } private void fileList_DragEnter(object sender, DragEventArgs args) { args.Effect = GetPastableFiles(args.Data, false).Any() ? DragDropEffects.Copy : DragDropEffects.None; } private async void fileList_DragDrop(object sender, DragEventArgs args) { try { await PasteFilesAsync(args.Data).ConfigureAwait(true); } catch (Exception e) { OnFileCopyFailed(e); } } //--------------------------------------------------------------------- // Clipboard handling. //--------------------------------------------------------------------- /// <summary> /// Create an IDataObject with the contents of the selected files. /// </summary> [SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Blocking calls made from worker thread")] internal VirtualFileDataObject CopySelectedFiles() { Precondition.ExpectNotNull(this.fileSystem, nameof(this.fileSystem)); // // Only consider files, ignore directories. // var files = this.SelectedFiles.Where(f => f.Type.IsFile); var dataObject = new VirtualFileDataObject(files .Select(f => new VirtualFileDataObject.Descriptor( f.Name, f.Size, f.Attributes, () => { // // NB. We're in a synchronous execution path here, // so we can't open the file asynchronously. // // However, this isn't a problem because this // is an asynchronous data object, so the call // should come from a worker thread, not the UI // thread. // return this.fileSystem! .OpenFileAsync(f, FileAccess.Read) .Result; })) .ToList()) { // // Don't block the UI thread when the target reads // the data. // IsAsync = true }; dataObject.AsyncOperationFailed += (_, args) => { Invoke(new Action(() => OnFileCopyFailed(args.Exception))); }; return dataObject; } internal static IEnumerable<FileInfo> GetPastableFiles( IDataObject dataObject) { // // Extract file paths, ignore directory paths. // return (dataObject.GetData(DataFormats.FileDrop) as IEnumerable<string>) .EnsureNotNull() .Where(path => File.Exists(path)) .Select(path => new FileInfo(path)); } /// <summary> /// Inspect the data object and extract the list of files /// that can be pasted to the current directory. /// </summary> internal IEnumerable<FileInfo> GetPastableFiles( IDataObject dataObject, bool promptForConflicts) { // // Extract file paths, ignore directory paths. // var files = GetPastableFiles(dataObject); if (!promptForConflicts) { return files; } // // Allow user to exclude files that would otherwise be overwritten. // Debug.Assert(this.currentDirectoryContents != null); Debug.Assert(this.currentDirectoryContents!.Count == this.fileList.Items.Count); var result = new List<FileInfo>(); foreach (var file in files) { var conflictingItem = this.currentDirectoryContents .FirstOrDefault(f => f.Name == file.Name); var dialogResult = DialogResult.OK; if (conflictingItem != null && !conflictingItem.Type.IsFile) { // // There is an existing directory with the same name // as the file to be dropped. // var parameters = new TaskDialogParameters( "Copy files", $"The destination already has a directory named '{conflictingItem.Name}'", string.Empty) { Icon = TaskDialogIcon.Error }; parameters.Buttons.Add(new TaskDialogCommandLinkButton( "Skip this file", DialogResult.Ignore)); parameters.Buttons.Add(TaskDialogStandardButton.Cancel); dialogResult = this.TaskDialog.ShowDialog(this, parameters); } else if (conflictingItem != null) { // // There is an existing file with the same name // as the file to be dropped. // var parameters = new TaskDialogParameters( "Copy files", $"The destination already has a file named '{conflictingItem.Name}'", string.Empty) { Icon = TaskDialogIcon.Warning }; parameters.Buttons.Add(new TaskDialogCommandLinkButton( "Replace the file in the destination", DialogResult.OK)); parameters.Buttons.Add(new TaskDialogCommandLinkButton( "Skip this file", DialogResult.Ignore)); parameters.Buttons.Add(TaskDialogStandardButton.Cancel); dialogResult = this.TaskDialog.ShowDialog(this, parameters); } switch (dialogResult) { case DialogResult.OK: result.Add(file); break; case DialogResult.Ignore: // // Skip this file. // break; case DialogResult.Cancel: return Array.Empty<FileInfo>(); } } return result; } /// <summary> /// Check of the data object contains pastable files. /// </summary> public static bool CanPaste(IDataObject dataObject) { return GetPastableFiles(dataObject).Any(); } /// <summary> /// Paste files to current directory. /// </summary> internal async Task PasteFilesAsync(IDataObject dataObject) { Precondition.ExpectNotNull(this.fileSystem, nameof(this.fileSystem)); Precondition.ExpectNotNull(this.navigationState, nameof(this.navigationState)); Debug.Assert(this.currentDirectoryContents != null); Debug.Assert(this.currentDirectoryContents!.Count == this.fileList.Items.Count); var filesToCopy = GetPastableFiles(dataObject, true); if (!filesToCopy.Any()) { return; } // // Show a progress dialog to track overall progress. // using (var progressDialog = this.ProgressDialog.StartCopyOperation( this, (ulong)filesToCopy.Count(), (ulong)filesToCopy.Sum(f => f.Length))) { var copyProgress = new Progress<int>( delta => progressDialog.OnBytesCompleted((ulong)delta)); foreach (var file in filesToCopy) { if (progressDialog.CancellationToken.IsCancellationRequested) { break; } try { // // Perform the file copy in the background. // // NB. If the underlying file system session is closed, // we expect I/O operations to be cancelled. // await Task .Run(async () => { using (var sourceStream = file.OpenRead()) using (var targetStream = await this.fileSystem! .OpenFileAsync( this.navigationState!.Directory, file.Name, FileMode.Create, FileAccess.Write) .ConfigureAwait(false)) { await sourceStream .CopyToAsync( targetStream, copyProgress, this.StreamCopyBufferSize, progressDialog.CancellationToken) .ConfigureAwait(false); } }) .ConfigureAwait(true); } catch (Exception e) when (e.IsCancellation()) { // // Ignore, and don't touch the UI because // it might no longer be in a good state. // return; } catch (Exception e) { // // Update dialog to avoid a situation where the // dialog still indicates that the copy is progressing // while we're showing an error dialog at the same time. // progressDialog.IsBlockedByError = true; var parameters = new TaskDialogParameters( "Copy files", $"Unable to copy {file.Name}", e.Unwrap().Message) { Icon = TaskDialogIcon.Error }; parameters.Buttons.Add(new TaskDialogCommandLinkButton( "Skip this file", DialogResult.Ignore)); parameters.Buttons.Add(TaskDialogStandardButton.Cancel); if (this.TaskDialog.ShowDialog(this, parameters) == DialogResult.Cancel) { return; } } finally { progressDialog.IsBlockedByError = false; progressDialog.OnItemCompleted(); } } } // // Refresh as there might be some new files now. // await RefreshAsync().ConfigureAwait(true); } //--------------------------------------------------------------------- // Publics. //--------------------------------------------------------------------- public async Task NavigateAsync(IEnumerable<string>? path) { Debug.Assert(!this.InvokeRequired, "Running on UI thread"); if (this.root == null || this.navigationState == null) { throw new InvalidOperationException("Control is not bound"); } // // Reset to root. // this.navigationState = this.root; if (path == null || !path.Any()) { await RebindToNavigationStateAsync().ConfigureAwait(true); } else { foreach (var pathItem in path) { await NavigateDownAsync(pathItem).ConfigureAwait(true); } } } public async Task NavigateDownAsync(string directoryName) { Debug.Assert(!this.InvokeRequired, "Running on UI thread"); if (this.root == null || this.navigationState == null) { throw new InvalidOperationException("Control is not bound"); } // // Expand the node and wait for it to be populated. // await this.navigationState.TreeNode .ExpandAsync() .ConfigureAwait(true); // // Make it visible. // this.navigationState.TreeNode.EnsureVisible(); // // Drill down. // var child = this.navigationState.TreeNode.Nodes .Cast<DirectoryTreeView.Node>() .FirstOrDefault(n => n.Model.Name == directoryName); if (child == null) { throw new ArgumentException($"The directory '{directoryName}' does not exist"); } this.navigationState = new Breadcrumb(this.navigationState, child); await RebindToNavigationStateAsync().ConfigureAwait(true); } public async Task NavigateUpAsync() { Debug.Assert(!this.InvokeRequired, "Running on UI thread"); if (this.root == null || this.navigationState == null) { throw new InvalidOperationException("Control is not bound"); } if (this.navigationState.Parent == null) { // // Already at root level. // return; } this.navigationState = this.navigationState.Parent; await RebindToNavigationStateAsync().ConfigureAwait(true); } public async Task RefreshAsync() { if (this.navigationState != null) { // // Clear cache and reload. // this.listFilesCache.Remove(this.navigationState.Directory); await RebindToNavigationStateAsync().ConfigureAwait(true); // // Force tree view to reload nodes in case a child // directory was added. // if (this.directoryTree.SelectedNode is BindableTreeView<IFileItem>.Node node) { node.Reload(); } } } //--------------------------------------------------------------------- // Inner classes. //--------------------------------------------------------------------- public class Breadcrumb { public Breadcrumb? Parent { get; } public IFileItem Directory => this.TreeNode.Model; internal DirectoryTreeView.Node TreeNode { get; } internal Breadcrumb( Breadcrumb? parent, BindableTreeView<IFileItem>.Node treeNode) { this.Parent = parent; this.TreeNode = treeNode.ExpectNotNull(nameof(treeNode)); } public IEnumerable<string> Path { get => this.Parent == null ? Enumerable.Empty<string>() : this.Parent.Path.ConcatItem(this.Directory.Name); } } } }