Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FluidExplorer.Navigation;
|
||||
using FluidExplorer.Services;
|
||||
using FluidExplorer.Services.FileSystem;
|
||||
using FluidExplorer.Services.ItemVisuals;
|
||||
using FluidExplorer.Services.Operations;
|
||||
using FluidExplorer.Services.Search;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using PathHelper = FluidExplorer.Services.FileSystem.PathHelper;
|
||||
|
||||
namespace FluidExplorer.ViewModels;
|
||||
|
||||
/// <summary>一个标签页:包含主窗格,可选第二窗格(双窗格模式,搬文件不用来回切)。</summary>
|
||||
public sealed partial class ExplorerTabViewModel : ObservableObject
|
||||
{
|
||||
private readonly IFileSystemService _fs;
|
||||
private readonly SearchService _search;
|
||||
private readonly IFileOperationService _operations;
|
||||
private readonly ItemVisualService _visuals;
|
||||
private readonly IFileClipboard _clipboard;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly DispatcherQueue _ui;
|
||||
|
||||
[ObservableProperty] private bool _isDualPane;
|
||||
[ObservableProperty] private bool _isActivePaneSecondary;
|
||||
[ObservableProperty] private string _header = "新标签页";
|
||||
[ObservableProperty] private string _glyph = "\uE8B7";
|
||||
|
||||
public ExplorerTabViewModel(
|
||||
IFileSystemService fs,
|
||||
SearchService search,
|
||||
IFileOperationService operations,
|
||||
ItemVisualService visuals,
|
||||
IFileClipboard clipboard,
|
||||
AppSettings settings,
|
||||
DispatcherQueue ui,
|
||||
NavigationLocation start)
|
||||
{
|
||||
_fs = fs;
|
||||
_search = search;
|
||||
_operations = operations;
|
||||
_visuals = visuals;
|
||||
_clipboard = clipboard;
|
||||
_settings = settings;
|
||||
_ui = ui;
|
||||
|
||||
Primary = new ExplorerPaneViewModel(fs, search, operations, visuals, clipboard, settings, ui);
|
||||
Primary.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(ExplorerPaneViewModel.Title)) UpdateHeader();
|
||||
};
|
||||
Primary.SettingsChanged += (_, _) => UpdateHeader();
|
||||
_ = Primary.NavigateAsync(start);
|
||||
UpdateHeader();
|
||||
}
|
||||
|
||||
public ExplorerPaneViewModel Primary { get; }
|
||||
|
||||
[ObservableProperty] private ExplorerPaneViewModel? _secondary;
|
||||
|
||||
public ExplorerPaneViewModel ActivePane => IsDualPane && IsActivePaneSecondary && Secondary is not null ? Secondary : Primary;
|
||||
|
||||
public ExplorerPaneViewModel EnsureSecondary()
|
||||
{
|
||||
if (Secondary is not null) return Secondary;
|
||||
var pane = new ExplorerPaneViewModel(_fs, _search, _operations, _visuals, _clipboard, _settings, _ui)
|
||||
{
|
||||
ShowSidebar = false
|
||||
};
|
||||
pane.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(ExplorerPaneViewModel.Title)) UpdateHeader();
|
||||
};
|
||||
Secondary = pane;
|
||||
return pane;
|
||||
}
|
||||
|
||||
public void ToggleDualPane()
|
||||
{
|
||||
if (IsDualPane)
|
||||
{
|
||||
IsDualPane = false;
|
||||
IsActivePaneSecondary = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var secondary = EnsureSecondary();
|
||||
if (string.IsNullOrEmpty(secondary.CurrentPath) || secondary.CurrentPath.StartsWith("::", StringComparison.Ordinal))
|
||||
{
|
||||
// 第二个窗格默认停在与主窗格同级的目录,便于对拖
|
||||
var parent = PathHelper.GetParent(Primary.CurrentPath);
|
||||
_ = secondary.NavigateAsync(NavigationLocation.FromPath(
|
||||
Directory.Exists(parent) ? parent : Primary.CurrentPath));
|
||||
}
|
||||
IsDualPane = true;
|
||||
}
|
||||
|
||||
private void UpdateHeader()
|
||||
{
|
||||
Header = Primary.IsSearchActive && !string.IsNullOrWhiteSpace(Primary.SearchText)
|
||||
? $"搜索:{Primary.SearchText}"
|
||||
: Primary.Title;
|
||||
Glyph = Primary.Glyph;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Primary.Dispose();
|
||||
Secondary?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FluidExplorer.Models;
|
||||
using FluidExplorer.Services.Operations;
|
||||
|
||||
namespace FluidExplorer.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// 操作队列面板里的一行。作业对象是在后台线程上更新并抛 PropertyChanged 的,
|
||||
/// 直接绑定会跨线程更新 UI 而崩溃,因此这里在 UI 线程维护一份快照,由定时器按 250ms 刷新。
|
||||
/// </summary>
|
||||
public sealed partial class JobRowViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _title = string.Empty;
|
||||
[ObservableProperty] private string _stateText = string.Empty;
|
||||
[ObservableProperty] private string _progressText = string.Empty;
|
||||
[ObservableProperty] private string _currentItem = string.Empty;
|
||||
[ObservableProperty] private double _progress;
|
||||
[ObservableProperty] private bool _isIndeterminate;
|
||||
[ObservableProperty] private bool _canPause;
|
||||
[ObservableProperty] private bool _canCancel;
|
||||
[ObservableProperty] private bool _isFinished;
|
||||
[ObservableProperty] private string _pauseGlyph = "\uE769";
|
||||
|
||||
public JobRowViewModel(FileOperationJob job)
|
||||
{
|
||||
Job = job;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public FileOperationJob Job { get; }
|
||||
|
||||
public void Refresh()
|
||||
{
|
||||
Title = Job.Title;
|
||||
StateText = Job.State switch
|
||||
{
|
||||
JobState.Queued => "排队中",
|
||||
JobState.Running => "进行中",
|
||||
JobState.Paused => "已暂停",
|
||||
JobState.Completed => "已完成",
|
||||
JobState.CompletedWithErrors => "已完成(部分出错)",
|
||||
JobState.Cancelled => "已取消",
|
||||
JobState.Failed => "失败",
|
||||
_ => Job.State.ToString()
|
||||
};
|
||||
|
||||
var parts = new List<string> { $"{Job.CompletedItems:N0} / {Job.TotalItems:N0} 个项目" };
|
||||
if (Job.TotalBytes > 0)
|
||||
parts.Add($"{FileEntry.FormatSize(Job.CompletedBytes)} / {FileEntry.FormatSize(Job.TotalBytes)}");
|
||||
if (Job.BytesPerSecond > 1) parts.Add($"{FileEntry.FormatSize((long)Job.BytesPerSecond)}/s");
|
||||
if (Job.Eta is { } eta && eta.TotalSeconds > 1 && eta.TotalHours < 24) parts.Add($"剩余 {eta:mm\\:ss}");
|
||||
ProgressText = string.Join(" · ", parts);
|
||||
|
||||
CurrentItem = Job.CurrentItem ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(Job.Error)) CurrentItem = Job.Error!;
|
||||
|
||||
Progress = Job.Progress;
|
||||
IsIndeterminate = Job.IsIndeterminate;
|
||||
CanPause = Job.CanPause;
|
||||
CanCancel = Job.CanCancel;
|
||||
IsFinished = Job.IsFinished;
|
||||
PauseGlyph = Job.State == JobState.Paused ? "\uE768" : "\uE769";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FluidExplorer.Navigation;
|
||||
using FluidExplorer.Services.FileSystem;
|
||||
using FluidExplorer.Services.Search;
|
||||
using FluidExplorer.Services.Shell;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
|
||||
namespace FluidExplorer.ViewModels;
|
||||
|
||||
/// <summary>侧边栏的一个节点(可展开的"此电脑"、可收藏的文件夹、驱动器等)。</summary>
|
||||
public sealed partial class SidebarNode : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private bool _isExpanded;
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
public SidebarNode(string displayName, string glyph, NavigationLocation location, bool isExpandable = false)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
Glyph = glyph;
|
||||
Location = location;
|
||||
IsExpandable = isExpandable;
|
||||
}
|
||||
|
||||
public string DisplayName { get; }
|
||||
public string Glyph { get; }
|
||||
public NavigationLocation Location { get; }
|
||||
public bool IsExpandable { get; }
|
||||
public bool CanPin { get; init; }
|
||||
|
||||
public ObservableCollection<SidebarNode> Children { get; } = [];
|
||||
|
||||
public bool HasUnrealizedChildren { get; set; }
|
||||
|
||||
/// <summary>当前文件夹是否属于该节点(用于侧边栏高亮,对齐资源管理器)。</summary>
|
||||
public bool Contains(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
if (Location.Kind is LocationKind.ThisPc or LocationKind.RecycleBin or LocationKind.Network or LocationKind.Home)
|
||||
return string.Equals(Location.Path, path, StringComparison.OrdinalIgnoreCase);
|
||||
var root = Location.Path.TrimEnd('\\');
|
||||
return path.StartsWith(root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static SidebarNode Create(string name, string glyph, NavigationLocation location, bool expandable = false, bool canPin = false)
|
||||
=> new(name, glyph, location, expandable) { CanPin = canPin };
|
||||
}
|
||||
|
||||
/// <summary>面包屑的一段。</summary>
|
||||
public sealed partial class BreadcrumbSegment : ObservableObject
|
||||
{
|
||||
public BreadcrumbSegment(string displayName, string path, bool isDriveRoot)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
Path = path;
|
||||
IsDriveRoot = isDriveRoot;
|
||||
}
|
||||
|
||||
public string DisplayName { get; }
|
||||
public string Path { get; }
|
||||
public bool IsDriveRoot { get; }
|
||||
}
|
||||
|
||||
/// <summary>搜索结果列表里的一行。</summary>
|
||||
public sealed partial class SearchResultItem : ObservableObject
|
||||
{
|
||||
private ImageSource? _icon;
|
||||
private ImageSource? _thumbnail;
|
||||
|
||||
public SearchResultItem(SearchHit hit) => Hit = hit;
|
||||
|
||||
public SearchHit Hit { get; }
|
||||
public string Name => Hit.Name;
|
||||
public string Directory => Hit.Directory;
|
||||
public string FullPath => Hit.Path;
|
||||
public bool IsDirectory => Hit.IsDirectory;
|
||||
public long Size => Hit.Size;
|
||||
public string SizeText => Hit.Size < 0 ? "—" : Hit.IsDirectory ? string.Empty : Models.FileEntry.FormatSize(Hit.Size);
|
||||
public string ModifiedText => Hit.ModifiedUtc == DateTime.MinValue || Hit.ModifiedUtc == default
|
||||
? "—"
|
||||
: Hit.ModifiedUtc.ToLocalTime().ToString("yyyy/MM/dd HH:mm");
|
||||
public string TypeText => Hit.IsDirectory ? "文件夹" : TypeNameResolver.GetTypeName(Hit.Extension, false);
|
||||
|
||||
public ImageSource? Icon
|
||||
{
|
||||
get => _icon;
|
||||
set { if (!ReferenceEquals(_icon, value)) { _icon = value; OnPropertyChanged(); } }
|
||||
}
|
||||
|
||||
public ImageSource? Thumbnail
|
||||
{
|
||||
get => _thumbnail;
|
||||
set { if (!ReferenceEquals(_thumbnail, value)) { _thumbnail = value; OnPropertyChanged(); } }
|
||||
}
|
||||
|
||||
public DateTime SortModified => Hit.ModifiedUtc;
|
||||
}
|
||||
Reference in New Issue
Block a user