Files

501 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}