- Faster enumeration of large directories - Add toggleable columns - Add Created and Type columns - Fix breadcrumbs - Properly build left view tree with support for cross-platform roots and special folders
460 lines
17 KiB
Text
460 lines
17 KiB
Text
@inherits OwningComponentBase
|
|
@using AntDesign.TableModels;
|
|
@using LANCommander.SDK.Services
|
|
@using Microsoft.Extensions.Logging
|
|
@inject IArchiveClient ArchiveClient
|
|
@inject IMessageService MessageService
|
|
@inject ILogger<FileManager> Logger
|
|
@namespace LANCommander.UI.Components
|
|
|
|
<div class="file-manager">
|
|
<GridRow Class="file-manager-nav" Align="RowAlign.Middle">
|
|
<Flex Align="FlexAlign.Center" Gap="FlexGap.Small" FlexCss="auto">
|
|
@if (Features.HasFlag(FileManagerFeatures.NavigationBack))
|
|
{
|
|
<Tooltip Title="Back" MouseEnterDelay="2">
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.ArrowLeft" OnClick="NavigateBack" Disabled="@(Past.Count == 0)"/>
|
|
</Tooltip>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.NavigationForward))
|
|
{
|
|
<Tooltip Title="Forward" MouseEnterDelay="2">
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.ArrowRight" OnClick="NavigateForward" Disabled="@(Future.Count == 0)"/>
|
|
</Tooltip>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.UpALevel))
|
|
{
|
|
<Tooltip Title="Up a Level" MouseEnterDelay="2">
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.ArrowUp" OnClick="NavigateUp" Disabled="@(FileSource.GetCurrentPath().Parent == null)"/>
|
|
</Tooltip>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.Refresh))
|
|
{
|
|
<Tooltip Title="Refresh" MouseEnterDelay="2">
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.Reload" OnClick="Refresh"/>
|
|
</Tooltip>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.Breadcrumbs))
|
|
{
|
|
<Breadcrumb Separator="/" Style="flex-grow: 1">
|
|
@foreach (var breadcrumb in Breadcrumbs)
|
|
{
|
|
<BreadcrumbItem OnClick="() => ChangeDirectory(breadcrumb, false)">@(breadcrumb.Parent == null ? breadcrumb.Name : breadcrumb.Name.TrimEnd('/', '\\'))</BreadcrumbItem>
|
|
}
|
|
</Breadcrumb>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.NewFolder))
|
|
{
|
|
<Tooltip Title="New Folder" MouseEnterDelay="2">
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.FolderAdd" OnClick="() => NewFolderModal.Open()"/>
|
|
</Tooltip>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.UploadFile))
|
|
{
|
|
<Tooltip Title="Upload File" MouseEnterDelay="2">
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.Upload" OnClick="() => UploadModal.Open()"/>
|
|
</Tooltip>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.Delete))
|
|
{
|
|
<Tooltip Title="Delete" MouseEnterDelay="2">
|
|
<Popconfirm OnConfirm="Delete">
|
|
<TitleTemplate>
|
|
Are you sure you want to delete the selected file@(Selected?.Count() == 1 ? "" : "s")?
|
|
</TitleTemplate>
|
|
<ChildContent>
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.Delete" Disabled="@(Selected?.Count() == 0)"/>
|
|
</ChildContent>
|
|
</Popconfirm>
|
|
</Tooltip>
|
|
}
|
|
|
|
@if (Features.HasFlag(FileManagerFeatures.ColumnPicker))
|
|
{
|
|
<Dropdown>
|
|
<Overlay>
|
|
<Menu>
|
|
<MenuItem OnClick="() => ToggleColumn(ColumnSize)">
|
|
<Checkbox Checked="IsColumnVisible(ColumnSize)" /> Size
|
|
</MenuItem>
|
|
<MenuItem OnClick="() => ToggleColumn(ColumnType)">
|
|
<Checkbox Checked="IsColumnVisible(ColumnType)" /> Type
|
|
</MenuItem>
|
|
<MenuItem OnClick="() => ToggleColumn(ColumnModified)">
|
|
<Checkbox Checked="IsColumnVisible(ColumnModified)" /> Modified
|
|
</MenuItem>
|
|
<MenuItem OnClick="() => ToggleColumn(ColumnCreated)">
|
|
<Checkbox Checked="IsColumnVisible(ColumnCreated)" /> Created
|
|
</MenuItem>
|
|
</Menu>
|
|
</Overlay>
|
|
<ChildContent>
|
|
<Tooltip Title="Columns" MouseEnterDelay="2">
|
|
<Button Type="@ButtonType.Text" Size="ButtonSize.Large" Icon="@IconType.Outline.Setting" />
|
|
</Tooltip>
|
|
</ChildContent>
|
|
</Dropdown>
|
|
}
|
|
</Flex>
|
|
</GridRow>
|
|
|
|
<SplitPane>
|
|
<Pane Size="25%">
|
|
<Tree TItem="FileManagerDirectory"
|
|
BlockNode="true"
|
|
DefaultExpandAll="false"
|
|
DefaultExpandParent="false"
|
|
DataSource="Directories"
|
|
SwitcherIcon="@IconType.Outline.Down"
|
|
TitleExpression="x => x.DataItem.Name"
|
|
ChildrenExpression="x => x.DataItem.Children"
|
|
IsLeafExpression="x => !x.DataItem.HasChildren"
|
|
IconExpression="x => x.Expanded ? IconType.Outline.FolderOpen : IconType.Outline.Folder"
|
|
OnClick="(args) => ChangeDirectory(args.Node.DataItem, false)"
|
|
OnNodeLoadDelayAsync="ExpandTree">
|
|
<SwitcherIconTemplate>
|
|
<Icon Type="@IconType.Outline.Down" />
|
|
</SwitcherIconTemplate>
|
|
<TitleIconTemplate>
|
|
@if (context.Expanded)
|
|
{
|
|
<Icon Type="@IconType.Outline.FolderOpen" />
|
|
}
|
|
else
|
|
{
|
|
<Icon Type="@IconType.Outline.Folder" />
|
|
}
|
|
</TitleIconTemplate>
|
|
</Tree>
|
|
</Pane>
|
|
<Pane Size="75%">
|
|
<Table TItem="IFileManagerEntry"
|
|
DataSource="Entries"
|
|
HidePagination="@(Entries != null && Entries.Count <= PageSize)"
|
|
PageSize="PageSize"
|
|
Loading="Entries == null"
|
|
OnRow="OnRow"
|
|
SelectedRowsChanged="SelectedChanged"
|
|
Size="@TableSize.Small">
|
|
<Selection Key="@context.Path" Type="@(SelectMultiple ? SelectionType.Checkbox : SelectionType.Radio)" Hidden="EntrySelectable == null" Disabled="!EntrySelectable.Invoke(context)" Class="@(EntrySelectable.Invoke(context) ? "" : "file-manager-selector-hidden")" />
|
|
<Column TData="string" Width="32">
|
|
@if (context is FileManagerFile)
|
|
{
|
|
<Icon Type="@(((FileManagerFile)context).GetIcon())" Theme="IconThemeType.Outline" />
|
|
}
|
|
else if (context is FileManagerDirectory)
|
|
{
|
|
<Icon Type="@IconType.Outline.Folder" />
|
|
}
|
|
</Column>
|
|
<PropertyColumn Property="e => e.Path" Sortable Title="Name">
|
|
@FileSource.GetEntryName(context)
|
|
</PropertyColumn>
|
|
@if (IsColumnVisible(ColumnSize))
|
|
{
|
|
<PropertyColumn Property="e => e.Size" Sortable Title="Size">
|
|
@if (context is FileManagerFile)
|
|
{
|
|
<ByteSize Value="context.Size" />
|
|
}
|
|
</PropertyColumn>
|
|
}
|
|
@if (IsColumnVisible(ColumnType))
|
|
{
|
|
<PropertyColumn Property="e => e.Name" Sortable Title="Type">
|
|
@if (context is FileManagerFile file)
|
|
{
|
|
@file.Extension.ToUpperInvariant()
|
|
}
|
|
else
|
|
{
|
|
<text>Folder</text>
|
|
}
|
|
</PropertyColumn>
|
|
}
|
|
@if (IsColumnVisible(ColumnModified))
|
|
{
|
|
<PropertyColumn Property="e => e.ModifiedOn" Format="MM/dd/yyyy hh:mm tt" Sortable Title="Modified" />
|
|
}
|
|
@if (IsColumnVisible(ColumnCreated))
|
|
{
|
|
<PropertyColumn Property="e => e.CreatedOn" Format="MM/dd/yyyy hh:mm tt" Sortable Title="Created" />
|
|
}
|
|
</Table>
|
|
</Pane>
|
|
</SplitPane>
|
|
</div>
|
|
|
|
<NewFolderModal @ref="NewFolderModal" OnFolderNameEntered="AddFolder" />
|
|
<UploadModal @ref="UploadModal" Path="@FileSource.GetCurrentPath().Path" OnUploadCompleted="() => Refresh()" />
|
|
|
|
@code {
|
|
[Parameter] public Guid? ArchiveId { get; set; }
|
|
[Parameter] public string WorkingDirectory { get; set; }
|
|
[Parameter] public bool SelectMultiple { get; set; } = true;
|
|
[Parameter] public bool IncludeDirectories { get; set; } = true;
|
|
[Parameter] public FileManagerDirectory CurrentPath { get; set; }
|
|
[Parameter] public EventCallback<FileManagerDirectory> CurrentPathChanged { get; set; }
|
|
[Parameter] public FileManagerFeatures Features { get; set; } = FileManagerFeatures.NavigationBack | FileManagerFeatures.NavigationForward | FileManagerFeatures.UpALevel | FileManagerFeatures.Refresh | FileManagerFeatures.Breadcrumbs | FileManagerFeatures.NewFolder | FileManagerFeatures.UploadFile | FileManagerFeatures.Delete | FileManagerFeatures.ColumnPicker;
|
|
[Parameter] public IEnumerable<IFileManagerEntry> Selected { get; set; } = new List<IFileManagerEntry>();
|
|
[Parameter] public EventCallback<IEnumerable<IFileManagerEntry>> SelectedChanged { get; set; }
|
|
[Parameter] public Func<IFileManagerEntry, bool> EntrySelectable { get; set; } = _ => true;
|
|
[Parameter] public Func<IFileManagerEntry, bool> EntryVisible { get; set; } = _ => true;
|
|
[Parameter] public Dictionary<string, bool> ColumnVisibility { get; set; }
|
|
[Parameter] public EventCallback<Dictionary<string, bool>> ColumnVisibilityChanged { get; set; }
|
|
|
|
const string ColumnSize = "Size";
|
|
const string ColumnType = "Type";
|
|
const string ColumnModified = "Modified";
|
|
const string ColumnCreated = "Created";
|
|
const int PageSize = 100;
|
|
|
|
IFileManagerSource FileSource;
|
|
|
|
List<FileManagerDirectory> Past { get; set; } = new();
|
|
List<FileManagerDirectory> Future { get; set; } = new();
|
|
List<FileManagerDirectory> Breadcrumbs = new();
|
|
|
|
List<IFileManagerEntry> Entries { get; set; } = new();
|
|
HashSet<FileManagerDirectory> Directories { get; set; } = new();
|
|
|
|
NewFolderModal NewFolderModal;
|
|
UploadModal UploadModal;
|
|
|
|
Dictionary<string, object> OnRow(RowData<IFileManagerEntry> row) => new()
|
|
{
|
|
["data-path"] = row.Data.Path,
|
|
["ondblclick"] = ((System.Action)delegate
|
|
{
|
|
if (row.Data is FileManagerDirectory)
|
|
ChangeDirectory((FileManagerDirectory)row.Data, true);
|
|
})
|
|
};
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
if (!String.IsNullOrWhiteSpace(WorkingDirectory))
|
|
FileSource = new FileManagerLocalDiskSource(WorkingDirectory);
|
|
else if (ArchiveId != null && ArchiveId != Guid.Empty)
|
|
FileSource = new FileManagerArchiveSource(ArchiveClient, ArchiveId.Value);
|
|
|
|
Directories = FileSource.GetDirectoryTree().ToHashSet();
|
|
|
|
if (FileSource is FileManagerLocalDiskSource)
|
|
{
|
|
var target = BuildDirectoryChain(WorkingDirectory);
|
|
await ChangeDirectory(target, true);
|
|
}
|
|
else if (FileSource is FileManagerArchiveSource)
|
|
await ChangeDirectory(Directories.First(), true);
|
|
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
async Task ChangeDirectory(FileManagerDirectory directory, bool clearFuture)
|
|
{
|
|
var currentPath = FileSource.GetCurrentPath();
|
|
|
|
if (currentPath != null && !String.IsNullOrWhiteSpace(currentPath.Path) && directory.Path != currentPath.Path && Past.LastOrDefault()?.Path != directory.Path)
|
|
Past.Add(currentPath);
|
|
|
|
CurrentPath = directory;
|
|
|
|
if (CurrentPathChanged.HasDelegate)
|
|
await CurrentPathChanged.InvokeAsync(CurrentPath);
|
|
|
|
FileSource.SetCurrentPath(directory);
|
|
|
|
await UpdateEntries();
|
|
UpdateBreadcrumbs();
|
|
|
|
if (clearFuture)
|
|
Future.Clear();
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
async Task ExpandTree(TreeEventArgs<FileManagerDirectory> args)
|
|
{
|
|
var directory = (FileManagerDirectory)args.Node.DataItem;
|
|
|
|
directory = FileSource.ExpandNode(directory);
|
|
}
|
|
|
|
async Task UpdateEntries()
|
|
{
|
|
Entries = FileSource.GetEntries().ToList();
|
|
}
|
|
|
|
void UpdateBreadcrumbs()
|
|
{
|
|
Breadcrumbs.Clear();
|
|
|
|
var currentPath = FileSource.GetCurrentPath();
|
|
|
|
while (currentPath != null)
|
|
{
|
|
Breadcrumbs.Add(currentPath);
|
|
|
|
currentPath = currentPath.Parent;
|
|
}
|
|
|
|
Breadcrumbs.Reverse();
|
|
}
|
|
|
|
async Task NavigateBack()
|
|
{
|
|
if (Past.Count > 0)
|
|
{
|
|
Future.Add(FileSource.GetCurrentPath());
|
|
await ChangeDirectory(Past.Last(), false);
|
|
Past = Past.Take(Past.Count - 1).ToList();
|
|
}
|
|
}
|
|
|
|
async Task NavigateForward()
|
|
{
|
|
if (Future.Count > 0)
|
|
{
|
|
Past.Add(FileSource.GetCurrentPath());
|
|
await ChangeDirectory(Future.First(), false);
|
|
Future = Future.Skip(1).ToList();
|
|
}
|
|
}
|
|
|
|
async Task NavigateUp()
|
|
{
|
|
var currentPath = FileSource.GetCurrentPath();
|
|
|
|
if (currentPath.Parent != null)
|
|
await ChangeDirectory(currentPath.Parent, true);
|
|
}
|
|
|
|
async Task Refresh()
|
|
{
|
|
await ChangeDirectory(FileSource.GetCurrentPath(), false);
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
async Task AddFolder(string name)
|
|
{
|
|
try
|
|
{
|
|
FileSource.CreateDirectory(System.IO.Path.Combine(FileSource.GetCurrentPath().Path, name));
|
|
|
|
await Refresh();
|
|
|
|
MessageService.Success("Folder created!");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageService.Error("Error creating folder!");
|
|
Logger.LogError(ex, "Error creating folder!");
|
|
}
|
|
}
|
|
|
|
async Task Delete()
|
|
{
|
|
try
|
|
{
|
|
foreach (var entry in Selected)
|
|
{
|
|
FileSource.DeleteEntry(entry);
|
|
}
|
|
|
|
Selected = new List<IFileManagerEntry>();
|
|
MessageService.Success("Deleted!");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageService.Error("Error deleting file/folder!");
|
|
Logger.LogError(ex, "Error deleting file/folder!");
|
|
}
|
|
|
|
await Refresh();
|
|
}
|
|
|
|
FileManagerDirectory BuildDirectoryChain(string path)
|
|
{
|
|
var segments = new List<string>();
|
|
var current = path;
|
|
|
|
while (!string.IsNullOrEmpty(current))
|
|
{
|
|
segments.Add(current);
|
|
var parentPath = Path.GetDirectoryName(current);
|
|
|
|
if (parentPath == current)
|
|
break;
|
|
|
|
current = parentPath;
|
|
}
|
|
|
|
segments.Reverse();
|
|
|
|
FileManagerDirectory parent = null;
|
|
|
|
// Try to match the first segment to a tree root
|
|
foreach (var root in Directories)
|
|
{
|
|
if (string.Equals(root.Path.TrimEnd(Path.DirectorySeparatorChar), segments[0].TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
parent = root;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no tree root matched, create one
|
|
if (parent == null)
|
|
{
|
|
parent = FileSource.GetDirectory(segments[0]);
|
|
parent.Parent = null;
|
|
}
|
|
|
|
// Build the chain for remaining segments
|
|
for (int i = 1; i < segments.Count; i++)
|
|
{
|
|
var dir = FileSource.GetDirectory(segments[i]);
|
|
dir.Parent = parent;
|
|
parent = dir;
|
|
}
|
|
|
|
return parent;
|
|
}
|
|
|
|
static readonly Dictionary<string, bool> ColumnDefaults = new()
|
|
{
|
|
{ ColumnSize, true },
|
|
{ ColumnType, false },
|
|
{ ColumnModified, true },
|
|
{ ColumnCreated, false },
|
|
};
|
|
|
|
bool IsColumnVisible(string column)
|
|
{
|
|
if (ColumnVisibility != null && ColumnVisibility.TryGetValue(column, out var visible))
|
|
return visible;
|
|
|
|
return ColumnDefaults.GetValueOrDefault(column, true);
|
|
}
|
|
|
|
async Task ToggleColumn(string column)
|
|
{
|
|
ColumnVisibility ??= new Dictionary<string, bool>();
|
|
|
|
ColumnVisibility[column] = !IsColumnVisible(column);
|
|
|
|
if (ColumnVisibilityChanged.HasDelegate)
|
|
await ColumnVisibilityChanged.InvokeAsync(ColumnVisibility);
|
|
|
|
StateHasChanged();
|
|
}
|
|
}
|