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);
}
}
}
}