Files
Explorer-Workbench/src/Explorer.Presentation/ViewModels/MainViewModel.cs
netquick e9aba73552 Add Explorer Workbench with hierarchical, off-UI Storage analysis.
Storage queries run in the background with cancellation and covering indexes so switching views no longer freezes the UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 12:43:05 +02:00

665 lines
18 KiB
C#

using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Search;
using Explorer.Analysis;
using Explorer.Domain.Abstractions;
using Explorer.Plugin.Abstractions;
namespace Explorer.Presentation.ViewModels;
public sealed partial class MainViewModel : ObservableObject
{
private readonly BrowseService _browse;
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
[ObservableProperty] private string _pathText = "";
[ObservableProperty] private string _theme = "Dark";
[ObservableProperty] private string _footer = "";
[ObservableProperty] private string? _promptUnc;
[ObservableProperty] private bool _showCloudPin;
[ObservableProperty] private bool _showCloudDehydrate;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
public MainViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources,
SearchService search,
AnalysisService analysis,
IIndexStore store,
TransferQueue transfers,
IOsClipboard clipboard,
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
_pathHistory = pathHistory;
_providers = providers;
_cloudPlaces = cloudPlaces;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces);
Search = new SearchViewModel(search, sources);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
Transfers = new TransferQueueViewModel(transfers);
Tabs = [];
Clipboard = clipboard;
transfers.JobFinished += (_, job) =>
{
void Go() => _ = OnTransferFinishedAsync(job);
if (_ui is { } ctx)
{
ctx.Post(_ => Go(), null);
}
else
{
Go();
}
};
_indexing.ProgressChanged += (_, p) =>
{
var text = p.Status == ScanJobStatus.Done
? $"Indexed {p.FilesSeen:N0} files"
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
if (_ui is { } ctx)
{
ctx.Post(_ => Footer = text, null);
}
else
{
Footer = text;
}
};
}
public ObservableCollection<ExplorerTabViewModel> Tabs { get; }
public ObservableCollection<string> PathHistory { get; }
public NavigationTreeViewModel Tree { get; }
public SearchViewModel Search { get; }
public AnalysisViewModel Analysis { get; }
public DuplicateViewModel Duplicates { get; }
public TransferQueueViewModel Transfers { get; }
public ExplorerPaneViewModel ActivePane => ActiveTab.ActivePane;
public async Task InitializeAsync()
{
await _sources.InitializeAsync().ConfigureAwait(true);
foreach (var path in _pathHistory.Load())
{
PathHistory.Add(path);
}
await NewTabAsync().ConfigureAwait(true);
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
Footer = "Ready";
}
[RelayCommand]
public async Task NewTabAsync()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources);
WireTab(tab);
Tabs.Add(tab);
ActiveTab = tab;
await tab.OpenInitialAsync().ConfigureAwait(true);
PathText = tab.ActivePane.CurrentPath;
}
[RelayCommand]
public void CloseTab(ExplorerTabViewModel? tab)
{
tab ??= ActiveTab;
if (Tabs.Count <= 1)
{
return;
}
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
ActiveTab = Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
[RelayCommand]
public void Split() => ActiveTab.ToggleSplit();
[RelayCommand]
public async Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
{
if (segment is null)
{
return;
}
await ActivePane.NavigateAsync(segment.Path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
}
[RelayCommand]
public void SetView(string? mode)
=> ActivePane.ViewMode = mode?.ToLowerInvariant() switch
{
"list" => FolderViewMode.List,
"preview" => FolderViewMode.Preview,
_ => FolderViewMode.Details
};
[RelayCommand]
public void CancelTransfer(TransferJob? job)
{
if (job is not null)
{
Transfers.Cancel(job);
}
}
[RelayCommand]
public Task BackAsync() => ActivePane.BackAsync();
[RelayCommand]
public Task ForwardAsync() => ActivePane.ForwardAsync();
[RelayCommand]
public Task UpAsync() => ActivePane.UpAsync();
[RelayCommand]
public Task RefreshAsync() => ActivePane.RefreshAsync();
[RelayCommand]
public async Task GoAsync()
{
var path = PathText.Trim();
if (string.IsNullOrEmpty(path))
{
return;
}
if (PathRules.IsUnc(path))
{
await _sources.AddUncAsync(path).ConfigureAwait(true);
await Tree.ReloadAsync(path).ConfigureAwait(true);
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
RememberEnteredPath(ActivePane.CurrentPath);
}
[RelayCommand]
public async Task TreeSelectAsync(NavNodeViewModel? node)
{
if (node is null || node.IsPlaceholder)
{
return;
}
await Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
if (!NavigationTreeViewModel.PathsEqual(node.Path, ActivePane.CurrentPath))
{
await ActivePane.NavigateAsync(node.Path).ConfigureAwait(true);
}
PathText = node.Path;
}
[RelayCommand]
public Task OpenSelectedAsync()
{
var item = ActivePane.SelectedItems.FirstOrDefault() ?? ActivePane.Items.FirstOrDefault();
return item is null ? Task.CompletedTask : ActivePane.OpenItemAsync(item);
}
[RelayCommand]
public void Copy()
{
_clipboard = SelectedPaths();
_clipboardIsCut = false;
CopyPathsToClipboard(_clipboard);
}
[RelayCommand]
public void Cut()
{
_clipboard = SelectedPaths();
_clipboardIsCut = true;
CopyPathsToClipboard(_clipboard);
}
[RelayCommand]
public async Task PasteAsync()
{
if (Clipboard.TryGetFiles(out var osFiles, out var cut) && osFiles.Count > 0)
{
_clipboard = osFiles.ToList();
_clipboardIsCut = cut;
}
if (_clipboard.Count == 0 || ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
{
return;
}
if (_clipboardIsCut)
{
await _ops.MoveAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
_clipboard = [];
}
else
{
await _ops.CopyAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
}
}
[RelayCommand]
public Task DeleteAsync()
{
var paths = SelectedPaths();
return paths.Count == 0 ? Task.CompletedTask : _ops.DeleteAsync(paths);
}
[RelayCommand]
public void CopyPath()
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
{
paths = [ActivePane.CurrentPath];
}
CopyPathsToClipboard(paths);
}
[RelayCommand]
public void NewFolder()
{
if (ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
{
return;
}
_ops.NewFolder(ActivePane.CurrentPath);
EnqueueReconcile(ActivePane.CurrentPath);
_ = ActivePane.RefreshAsync();
}
public void RenameSelected(string newName)
{
var item = ActivePane.SelectedItems.FirstOrDefault();
if (item is null)
{
return;
}
_ops.Rename(item.FullPath, newName);
EnqueueReconcile(ActivePane.CurrentPath);
_ = ActivePane.RefreshAsync();
}
[RelayCommand]
public async Task BuildIndexAsync()
{
var path = ActivePane.CurrentPath;
if (path == "This PC")
{
path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? "";
}
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
{
Footer = "Select a drive or folder to index.";
return;
}
var source = await _sources.EnsureForPathAsync(path).ConfigureAwait(true);
if (source is null)
{
Footer = "This location could not be indexed.";
return;
}
ActivePane.CurrentSource = source;
ActivePane.ShowIndexBanner = false;
ActivePane.IndexBannerText = null;
_indexing.EnqueueFullScan(source.Id);
Footer = $"Indexing {source.DisplayName}…";
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
}
[RelayCommand]
public void RescanFolder() => ActivePane.RescanFolder();
public void RefreshCloudActions()
{
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
}
[RelayCommand]
public Task PinCloudAsync() => InvokeCloudAsync(ProviderAction.Pin);
[RelayCommand]
public Task UnpinCloudAsync() => InvokeCloudAsync(ProviderAction.Unpin);
[RelayCommand]
public Task FreeUpCloudSpaceAsync() => InvokeCloudAsync(ProviderAction.Dehydrate);
private async Task InvokeCloudAsync(ProviderAction action)
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
{
paths = [ActivePane.CurrentPath];
}
var result = await _providers.InvokeAsync(action, paths).ConfigureAwait(true);
Footer = result.Message ?? (result.Status == ProviderActionStatus.Succeeded
? "Asked the cloud client to update these items."
: "Cloud action was not available.");
await ActivePane.RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
public void CancelIndex()
{
if (ActivePane.CurrentSource is not null)
{
_indexing.Cancel(ActivePane.CurrentSource.Id);
}
}
[RelayCommand]
public async Task OpenStorageHereAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Analysis.Close();
}
[RelayCommand]
public async Task OpenStorageOtherAsync()
{
if (!ActiveTab.IsSplit || !Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
var other = ActiveTab.ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
ActiveTab.Activate(other);
await other.NavigateAsync(path).ConfigureAwait(true);
PathText = other.CurrentPath;
}
[RelayCommand]
public async Task OpenStorageTabAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await NewTabAsync().ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
}
[RelayCommand]
public async Task SearchStorageAsync()
{
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
{
return;
}
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Search.Scope = SearchScopeKind.CurrentTree;
Search.OpenWithoutSearch();
Analysis.Close();
}
[RelayCommand]
public void RescanStorage()
{
if (Analysis.SelectedSourceId is not long id)
{
return;
}
if (Analysis.SelectedIsSource)
{
_indexing.EnqueueFullScan(id);
}
else
{
_indexing.EnqueueFolderScan(id, Analysis.SelectedScanPathRel);
}
Footer = "Queued an index rescan for this location.";
}
[RelayCommand]
public void CopyStoragePath()
{
if (Analysis.SelectedPath is { } path)
{
CopyPathsToClipboard([path]);
}
}
[RelayCommand]
public async Task AddNetworkAsync()
{
if (string.IsNullOrWhiteSpace(PromptUnc))
{
return;
}
await _sources.AddUncAsync(PromptUnc.Trim()).ConfigureAwait(true);
PromptUnc = "";
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
}
public async Task AddCloudFolderAsync(string path, string? displayName = null)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
_cloudPlaces.Add(OneDriveProviderId, path, displayName);
Footer = "Added OneDrive folder to the navigation tree.";
await Tree.ReloadAsync(path.Trim().TrimEnd('\\')).ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
}
private const string OneDriveProviderId = "onedrive";
[RelayCommand]
public Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(Search.Text) && !Search.IsOpen)
{
Search.OpenWithoutSearch();
return Task.CompletedTask;
}
return Search.RunAsync(ActivePane);
}
[RelayCommand]
public void ToggleTheme() => Theme = Theme == "Dark" ? "Light" : "Dark";
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, bool move)
{
if (files.Count == 0)
{
return;
}
if (move)
{
await _ops.MoveAsync(files, targetDirectory).ConfigureAwait(true);
}
else
{
await _ops.CopyAsync(files, targetDirectory).ConfigureAwait(true);
}
}
private void WireTab(ExplorerTabViewModel tab)
{
tab.Left.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
tab.Right.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
tab.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ExplorerTabViewModel.ActivePane) && tab == ActiveTab)
{
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
}
};
}
private void OnPaneProperty(ExplorerTabViewModel tab, string? propertyName)
{
if (tab != ActiveTab)
{
return;
}
if (propertyName == nameof(ExplorerPaneViewModel.CurrentPath))
{
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
RefreshCloudActions();
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
}
}
private async Task OnTransferFinishedAsync(TransferJob job)
{
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
foreach (var part in path.Split('|', StringSplitOptions.RemoveEmptyEntries))
{
var dir = Directory.Exists(part) ? part : PathRules.Parent(part);
if (!string.IsNullOrEmpty(dir))
{
dirs.Add(dir);
}
}
}
Add(job.SourcePath);
Add(job.DestinationPath);
foreach (var extra in job.AdditionalSources)
{
Add(extra);
}
foreach (var dir in dirs)
{
EnqueueReconcile(dir);
}
await ActivePane.RefreshAsync().ConfigureAwait(true);
if (ActiveTab.IsSplit)
{
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
await other.RefreshAsync().ConfigureAwait(true);
}
}
private void EnqueueReconcile(string path)
{
_ = ReconcileAsync(path);
}
private async Task ReconcileAsync(string path)
{
try
{
var source = await _sources.FindByPathAsync(path).ConfigureAwait(true);
if (source is not { IsIndexed: true } || source.LastRootPath is null)
{
return;
}
var rel = PathRules.MakeRelative(source.LastRootPath, path);
_indexing.EnqueueReconcile(source.Id, rel);
}
catch
{
// local errors stay out of the UI
}
}
public void RememberEnteredPath(string path)
{
var next = PathHistoryStore.Remember(PathHistory, path);
if (next.Count == PathHistory.Count
&& PathHistory.Count > 0
&& string.Equals(PathHistory[0], path, StringComparison.OrdinalIgnoreCase))
{
return;
}
PathHistory.Clear();
foreach (var item in next)
{
PathHistory.Add(item);
}
_pathHistory.Save(PathHistory);
}
private List<string> SelectedPaths()
=> ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
private void CopyPathsToClipboard(IReadOnlyList<string> paths)
{
if (paths.Count == 0)
{
return;
}
Clipboard.SetFiles(paths, _clipboardIsCut);
}
partial void OnActiveTabChanged(ExplorerTabViewModel value)
{
PathText = value.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
}
}