501 lines
18 KiB
C#
501 lines
18 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.Diagnostics;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using FluidExplorer.Navigation;
|
||
using FluidExplorer.Services;
|
||
using FluidExplorer.Services.FileSystem;
|
||
using FluidExplorer.Services.Icons;
|
||
using FluidExplorer.Services.ItemVisuals;
|
||
using FluidExplorer.Services.Operations;
|
||
using FluidExplorer.Services.Search;
|
||
using FluidExplorer.Services.Shell;
|
||
using FluidExplorer.Helpers;
|
||
using Microsoft.UI.Dispatching;
|
||
using PathHelper = FluidExplorer.Services.FileSystem.PathHelper;
|
||
|
||
namespace FluidExplorer.ViewModels;
|
||
|
||
/// <summary>主视图模型:标签页、侧边栏、索引状态、操作队列、设置。</summary>
|
||
public sealed partial class MainViewModel : ObservableObject
|
||
{
|
||
private readonly IFileSystemService _fs;
|
||
private readonly SearchService _search;
|
||
private readonly IFileOperationService _operations;
|
||
private readonly AppSettings _settings;
|
||
private readonly DispatcherQueue _ui;
|
||
|
||
public MainViewModel(
|
||
IFileSystemService fs,
|
||
SearchService search,
|
||
IFileOperationService operations,
|
||
IIconService icons,
|
||
AppSettings settings,
|
||
DispatcherQueue ui)
|
||
{
|
||
_fs = fs;
|
||
_search = search;
|
||
_operations = operations;
|
||
_settings = settings;
|
||
_ui = ui;
|
||
|
||
Clipboard = new FileClipboard();
|
||
Visuals = new ItemVisualService(icons, ui);
|
||
|
||
ShowHiddenFiles = settings.ShowHiddenFiles;
|
||
ShowSystemFiles = settings.ShowSystemFiles;
|
||
ShowFileExtensions = settings.ShowFileExtensions;
|
||
AlwaysShowCheckBoxes = settings.AlwaysShowCheckBoxes;
|
||
DeleteToRecycleBin = settings.DeleteToRecycleBin;
|
||
AnimationsEnabled = settings.AnimationsEnabled;
|
||
SearchInCurrentFolderOnly = settings.SearchScope == SearchScope.CurrentFolder;
|
||
ThemeMode = settings.ThemeMode;
|
||
|
||
_operations.JobsChanged += (_, _) => RefreshJobs();
|
||
_search.IndexStateChanged += (_, _) => _ui.Post(RefreshIndexState);
|
||
|
||
_jobTimer = _ui.CreateTimer();
|
||
_jobTimer.Interval = TimeSpan.FromMilliseconds(250);
|
||
_jobTimer.IsRepeating = true;
|
||
_jobTimer.Tick += OnJobTimerTick;
|
||
|
||
BuildSidebar();
|
||
RefreshIndexState();
|
||
RefreshJobs();
|
||
}
|
||
|
||
public ItemVisualService Visuals { get; }
|
||
public IFileClipboard Clipboard { get; }
|
||
public AppSettings Settings => _settings;
|
||
|
||
// ── 标签页 ──────────────────────────────────────────────────────────────
|
||
public ObservableCollection<ExplorerTabViewModel> Tabs { get; } = [];
|
||
[ObservableProperty] private ExplorerTabViewModel? _selectedTab;
|
||
|
||
public ExplorerPaneViewModel? ActivePane => SelectedTab?.ActivePane;
|
||
|
||
public ExplorerTabViewModel AddTab(NavigationLocation? start = null, bool select = true)
|
||
{
|
||
var location = start ?? DefaultStartLocation();
|
||
var tab = new ExplorerTabViewModel(_fs, _search, _operations, Visuals, Clipboard, _settings, _ui, location);
|
||
tab.PropertyChanged += (_, _) => { if (ReferenceEquals(tab, SelectedTab)) OnPropertyChanged(nameof(ActivePane)); };
|
||
Tabs.Add(tab);
|
||
if (select) SelectedTab = tab;
|
||
SaveOpenTabs();
|
||
return tab;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void NewTab() => AddTab();
|
||
|
||
[RelayCommand]
|
||
private void CloseTab(ExplorerTabViewModel? tab)
|
||
{
|
||
tab ??= SelectedTab;
|
||
if (tab is null) return;
|
||
var index = Tabs.IndexOf(tab);
|
||
tab.Dispose();
|
||
Tabs.Remove(tab);
|
||
if (Tabs.Count == 0)
|
||
{
|
||
AddTab();
|
||
return;
|
||
}
|
||
SelectedTab = Tabs[Math.Clamp(index, 0, Tabs.Count - 1)];
|
||
SaveOpenTabs();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void DuplicateTab(ExplorerTabViewModel? tab)
|
||
{
|
||
tab ??= SelectedTab;
|
||
if (tab is null) return;
|
||
AddTab(NavigationLocation.FromPath(tab.Primary.CurrentPath));
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ToggleDualPane()
|
||
{
|
||
SelectedTab?.ToggleDualPane();
|
||
_settings.DualPane = SelectedTab?.IsDualPane ?? false;
|
||
_settings.Save();
|
||
}
|
||
|
||
partial void OnSelectedTabChanged(ExplorerTabViewModel? value)
|
||
{
|
||
OnPropertyChanged(nameof(ActivePane));
|
||
if (value is not null) UpdateSidebarSelection(value.ActivePane.CurrentPath);
|
||
}
|
||
|
||
private NavigationLocation DefaultStartLocation()
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(_settings.DefaultStartPath) && Directory.Exists(_settings.DefaultStartPath))
|
||
return NavigationLocation.FromPath(_settings.DefaultStartPath);
|
||
var downloads = KnownFolders.Downloads;
|
||
return Directory.Exists(downloads) ? NavigationLocation.FromPath(downloads) : NavigationLocation.Home;
|
||
}
|
||
|
||
// ── 侧边栏 ──────────────────────────────────────────────────────────────
|
||
public ObservableCollection<SidebarNode> SidebarRoots { get; } = [];
|
||
[ObservableProperty] private SidebarNode? _selectedSidebarNode;
|
||
|
||
private void BuildSidebar()
|
||
{
|
||
SidebarRoots.Clear();
|
||
SidebarRoots.Add(SidebarNode.Create("主页", "\uE80F", NavigationLocation.Home));
|
||
SidebarRoots.Add(SidebarNode.Create("图库", "\uE91B", NavigationLocation.Gallery));
|
||
|
||
if (_settings.PinnedFolders.Count == 0)
|
||
{
|
||
foreach (var path in new[] { KnownFolders.Desktop, KnownFolders.Downloads, KnownFolders.Documents, KnownFolders.Pictures })
|
||
if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) _settings.PinnedFolders.Add(path);
|
||
}
|
||
|
||
var quick = SidebarNode.Create("快速访问", "\uE8B7", NavigationLocation.Home, expandable: true);
|
||
quick.IsExpanded = true;
|
||
foreach (var path in _settings.PinnedFolders)
|
||
{
|
||
if (!Directory.Exists(path)) continue;
|
||
quick.Children.Add(SidebarNode.Create(PathHelper.GetName(path), "\uE8B7", NavigationLocation.FromPath(path), canPin: true));
|
||
}
|
||
SidebarRoots.Add(quick);
|
||
|
||
var thisPc = SidebarNode.Create("此电脑", "\uE977", NavigationLocation.ThisPc, expandable: true);
|
||
thisPc.IsExpanded = true;
|
||
foreach (var drive in DriveItem.Enumerate())
|
||
{
|
||
thisPc.Children.Add(SidebarNode.Create(drive.DisplayName, drive.Glyph,
|
||
new NavigationLocation(LocationKind.Drive, drive.RootPath, drive.DisplayName, drive.Glyph)));
|
||
}
|
||
SidebarRoots.Add(thisPc);
|
||
|
||
SidebarRoots.Add(SidebarNode.Create("网络", "\uE968", NavigationLocation.Network));
|
||
SidebarRoots.Add(SidebarNode.Create("回收站", "\uE74D", NavigationLocation.RecycleBin));
|
||
}
|
||
|
||
public void UpdateSidebarSelection(string path)
|
||
{
|
||
if (string.IsNullOrEmpty(path)) return;
|
||
foreach (var node in EnumerateNodes(SidebarRoots))
|
||
{
|
||
var match = node.Location.Kind switch
|
||
{
|
||
LocationKind.Home or LocationKind.Gallery or LocationKind.ThisPc or LocationKind.Network or LocationKind.RecycleBin
|
||
=> string.Equals(node.Location.Path, path, StringComparison.OrdinalIgnoreCase),
|
||
LocationKind.Drive => string.Equals(node.Location.Path.TrimEnd('\\'), path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase),
|
||
_ => false
|
||
};
|
||
if (match)
|
||
{
|
||
SelectedSidebarNode = node;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
private static IEnumerable<SidebarNode> EnumerateNodes(IEnumerable<SidebarNode> nodes)
|
||
{
|
||
foreach (var node in nodes)
|
||
{
|
||
yield return node;
|
||
foreach (var child in EnumerateNodes(node.Children)) yield return child;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task NavigateSidebarAsync(SidebarNode? node)
|
||
{
|
||
if (node is null) return;
|
||
var pane = ActivePane ?? AddTab(node.Location).Primary;
|
||
await pane.NavigateAsync(node.Location);
|
||
UpdateSidebarSelection(node.Location.Path);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void PinFolder()
|
||
{
|
||
var pane = ActivePane;
|
||
if (pane is null || pane.Location.Kind is not (LocationKind.Folder or LocationKind.Drive)) return;
|
||
if (_settings.PinnedFolders.Contains(pane.CurrentPath, StringComparer.OrdinalIgnoreCase)) return;
|
||
_settings.PinnedFolders.Add(pane.CurrentPath);
|
||
_settings.Save();
|
||
BuildSidebar();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void UnpinFolder(SidebarNode? node)
|
||
{
|
||
if (node is null) return;
|
||
_settings.PinnedFolders.RemoveAll(p => string.Equals(p, node.Location.Path, StringComparison.OrdinalIgnoreCase));
|
||
_settings.Save();
|
||
BuildSidebar();
|
||
}
|
||
|
||
// ── 索引状态 ────────────────────────────────────────────────────────────
|
||
[ObservableProperty] private string _indexSummary = "索引:未启动";
|
||
[ObservableProperty] private bool _isIndexing;
|
||
[ObservableProperty] private double _indexProgress;
|
||
[ObservableProperty] private bool _indexNeedsElevation;
|
||
[ObservableProperty] private string _indexDetail = string.Empty;
|
||
|
||
private void RefreshIndexState()
|
||
{
|
||
var state = _search.AggregateState;
|
||
var count = _search.TotalIndexedEntries;
|
||
IndexDetail = _search.AllIndexes.FirstOrDefault() is { } first
|
||
? first.GetType().Name
|
||
: string.Empty;
|
||
|
||
switch (state)
|
||
{
|
||
case IndexState.NotStarted:
|
||
IndexSummary = "索引:未启动(搜索将退化为实时扫描)";
|
||
IsIndexing = false;
|
||
break;
|
||
case IndexState.Building:
|
||
IndexSummary = $"索引:正在建立…(已索引 {count:N0} 项)";
|
||
IsIndexing = true;
|
||
break;
|
||
case IndexState.RequiresElevation:
|
||
IndexSummary = "索引:需要管理员权限才能读取 NTFS 主文件表";
|
||
IsIndexing = false;
|
||
IndexNeedsElevation = true;
|
||
break;
|
||
case IndexState.Failed:
|
||
IndexSummary = "索引:不可用(该卷不是 NTFS)";
|
||
IsIndexing = false;
|
||
break;
|
||
default:
|
||
IndexSummary = $"索引:{count:N0} 项 · 已就绪";
|
||
IsIndexing = false;
|
||
IndexNeedsElevation = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task BuildIndexAsync()
|
||
{
|
||
if (IsIndexing) return;
|
||
IsIndexing = true;
|
||
IndexSummary = "索引:正在建立…";
|
||
|
||
var volumes = DriveItem.Enumerate()
|
||
.Where(d => d.DriveType == 3 && d.IsReady)
|
||
.Select(d => d.RootPath)
|
||
.ToList();
|
||
if (volumes.Count == 0) volumes.Add(Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\");
|
||
|
||
_settings.IndexedVolumes = volumes;
|
||
_settings.Save();
|
||
|
||
var progress = new Progress<(string Volume, double Progress)>(p => _ui.Post(() =>
|
||
{
|
||
IndexProgress = p.Progress;
|
||
IndexSummary = $"索引:正在读取 {p.Volume} … {p.Progress:P0}";
|
||
}));
|
||
|
||
try
|
||
{
|
||
await _search.BuildIndexesAsync(volumes, progress, CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
IndexSummary = $"索引:失败({ex.Message})";
|
||
}
|
||
finally
|
||
{
|
||
IsIndexing = false;
|
||
RefreshIndexState();
|
||
}
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void RestartElevated()
|
||
{
|
||
try
|
||
{
|
||
var exe = Environment.ProcessPath;
|
||
if (string.IsNullOrEmpty(exe)) return;
|
||
Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true, Verb = "runas" });
|
||
Microsoft.UI.Xaml.Application.Current.Exit();
|
||
}
|
||
catch
|
||
{
|
||
// 用户拒绝了 UAC
|
||
}
|
||
}
|
||
|
||
// ── 操作队列 ────────────────────────────────────────────────────────────
|
||
public ObservableCollection<JobRowViewModel> JobRows { get; } = [];
|
||
[ObservableProperty] private bool _isQueueExpanded;
|
||
[ObservableProperty] private int _activeJobCount;
|
||
private readonly DispatcherQueueTimer _jobTimer;
|
||
|
||
private void RefreshJobs()
|
||
{
|
||
_ui.Post(() =>
|
||
{
|
||
JobRows.Clear();
|
||
foreach (var job in _operations.Jobs) JobRows.Add(new JobRowViewModel(job));
|
||
ActiveJobCount = JobRows.Count(j => !j.IsFinished);
|
||
OnPropertyChanged(nameof(HasJobs));
|
||
|
||
// 有活动作业时以 250ms 刷新快照(作业本身在后台线程更新,不能直接绑 UI)
|
||
if (ActiveJobCount > 0) _jobTimer.Start();
|
||
else _jobTimer.Stop();
|
||
});
|
||
}
|
||
|
||
private void OnJobTimerTick(DispatcherQueueTimer sender, object args)
|
||
{
|
||
var anyActive = false;
|
||
foreach (var row in JobRows)
|
||
{
|
||
row.Refresh();
|
||
if (!row.IsFinished) anyActive = true;
|
||
}
|
||
ActiveJobCount = JobRows.Count(r => !r.IsFinished);
|
||
if (!anyActive) sender.Stop();
|
||
}
|
||
|
||
public bool HasJobs => JobRows.Count > 0;
|
||
|
||
[RelayCommand]
|
||
private void PauseJob(JobRowViewModel? row)
|
||
{
|
||
if (row is null) return;
|
||
if (row.Job.State == JobState.Paused) _operations.Resume(row.Job.Id);
|
||
else _operations.Pause(row.Job.Id);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CancelJob(JobRowViewModel? row)
|
||
{
|
||
if (row is null) return;
|
||
_operations.Cancel(row.Job.Id);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ClearFinishedJobs()
|
||
{
|
||
_operations.ClearFinished();
|
||
RefreshJobs();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task UndoAsync()
|
||
{
|
||
var result = await _operations.UndoAsync();
|
||
if (!result.Success) ErrorMessage = result.Error;
|
||
await (ActivePane?.RefreshCommand.ExecuteAsync(null) ?? Task.CompletedTask);
|
||
}
|
||
|
||
[ObservableProperty] private string? _errorMessage;
|
||
|
||
// ── 设置(改动即时生效) ────────────────────────────────────────────────
|
||
[ObservableProperty] private bool _showHiddenFiles;
|
||
[ObservableProperty] private bool _showSystemFiles;
|
||
[ObservableProperty] private bool _showFileExtensions;
|
||
[ObservableProperty] private bool _alwaysShowCheckBoxes;
|
||
[ObservableProperty] private bool _deleteToRecycleBin;
|
||
[ObservableProperty] private bool _animationsEnabled;
|
||
[ObservableProperty] private bool _searchInCurrentFolderOnly;
|
||
[ObservableProperty] private AppThemeMode _themeMode;
|
||
|
||
partial void OnShowHiddenFilesChanged(bool value)
|
||
{
|
||
_settings.ShowHiddenFiles = value;
|
||
_settings.Save();
|
||
_ = ActivePane?.RefreshCommand.ExecuteAsync(null);
|
||
}
|
||
|
||
partial void OnShowSystemFilesChanged(bool value)
|
||
{
|
||
_settings.ShowSystemFiles = value;
|
||
_settings.Save();
|
||
_ = ActivePane?.RefreshCommand.ExecuteAsync(null);
|
||
}
|
||
|
||
partial void OnShowFileExtensionsChanged(bool value)
|
||
{
|
||
_settings.ShowFileExtensions = value;
|
||
_settings.Save();
|
||
foreach (var pane in AllPanes()) pane.RebuildDisplayNames();
|
||
}
|
||
|
||
partial void OnAlwaysShowCheckBoxesChanged(bool value)
|
||
{
|
||
_settings.AlwaysShowCheckBoxes = value;
|
||
_settings.Save();
|
||
}
|
||
|
||
partial void OnDeleteToRecycleBinChanged(bool value)
|
||
{
|
||
_settings.DeleteToRecycleBin = value;
|
||
_settings.Save();
|
||
}
|
||
|
||
partial void OnAnimationsEnabledChanged(bool value)
|
||
{
|
||
_settings.AnimationsEnabled = value;
|
||
_settings.Save();
|
||
AnimationsToggled?.Invoke(this, value);
|
||
}
|
||
|
||
public event EventHandler<bool>? AnimationsToggled;
|
||
public event EventHandler<AppThemeMode>? ThemeModeChanged;
|
||
|
||
partial void OnThemeModeChanged(AppThemeMode value)
|
||
{
|
||
_settings.ThemeMode = value;
|
||
_settings.Save();
|
||
ThemeModeChanged?.Invoke(this, value);
|
||
}
|
||
|
||
partial void OnSearchInCurrentFolderOnlyChanged(bool value)
|
||
{
|
||
_settings.SearchScope = value ? SearchScope.CurrentFolder : SearchScope.Global;
|
||
_settings.Save();
|
||
}
|
||
|
||
private IEnumerable<ExplorerPaneViewModel> AllPanes()
|
||
{
|
||
foreach (var tab in Tabs)
|
||
{
|
||
yield return tab.Primary;
|
||
if (tab.Secondary is not null) yield return tab.Secondary;
|
||
}
|
||
}
|
||
|
||
public void SaveOpenTabs()
|
||
{
|
||
_settings.OpenTabs = Tabs
|
||
.Select(t => t.Primary.CurrentPath)
|
||
.Where(p => !string.IsNullOrEmpty(p) && !p.StartsWith("::", StringComparison.Ordinal))
|
||
.ToList();
|
||
_settings.Save();
|
||
}
|
||
|
||
/// <summary>启动:恢复上次的标签页并建立索引。</summary>
|
||
public async Task InitializeAsync()
|
||
{
|
||
if (_settings.RestoreTabsOnStartup && _settings.OpenTabs.Count > 0)
|
||
{
|
||
foreach (var path in _settings.OpenTabs.Take(8))
|
||
{
|
||
if (Directory.Exists(path)) AddTab(NavigationLocation.FromPath(path), select: false);
|
||
}
|
||
}
|
||
if (Tabs.Count == 0) AddTab();
|
||
SelectedTab = Tabs[0];
|
||
|
||
if (_settings.DualPane) SelectedTab?.ToggleDualPane();
|
||
|
||
if (_settings.IndexOnStartup)
|
||
{
|
||
await BuildIndexAsync();
|
||
}
|
||
}
|
||
}
|