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 _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 Tabs { get; } public ObservableCollection 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 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(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 SelectedPaths() => ActivePane.SelectedItems.Select(i => i.FullPath).ToList(); private void CopyPathsToClipboard(IReadOnlyList paths) { if (paths.Count == 0) { return; } Clipboard.SetFiles(paths, _clipboardIsCut); } partial void OnActiveTabChanged(ExplorerTabViewModel value) { PathText = value.ActivePane.CurrentPath; OnPropertyChanged(nameof(ActivePane)); } }