using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluidExplorer.Models;
using FluidExplorer.Navigation;
using FluidExplorer.Services;
using FluidExplorer.Services.FileSystem;
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;
/// 复制/剪切剪贴板(应用内共享,跨窗格粘贴是"搬文件"最快的方式)。
public interface IFileClipboard
{
IReadOnlyList Paths { get; }
bool IsCut { get; }
void Set(IReadOnlyList paths, bool cut);
void Clear();
event EventHandler? Changed;
}
public sealed class FileClipboard : IFileClipboard
{
private List _paths = [];
public IReadOnlyList Paths => _paths;
public bool IsCut { get; private set; }
public event EventHandler? Changed;
public void Set(IReadOnlyList paths, bool cut)
{
_paths = [.. paths];
IsCut = cut;
Changed?.Invoke(this, EventArgs.Empty);
}
public void Clear()
{
_paths = [];
IsCut = false;
Changed?.Invoke(this, EventArgs.Empty);
}
}
///
/// 一个浏览窗格的全部状态与行为(单窗格 = 一个标签页;双窗格 = 同一标签页里的两个实例)。
/// 所有 IO 都是异步 + 可取消的,界面线程永远不会被文件系统拖住。
///
public sealed partial class ExplorerPaneViewModel : ObservableObject
{
private readonly IFileSystemService _fs;
private readonly SearchService _search;
private readonly IFileOperationService _operations;
private readonly ItemVisualService _visuals;
private readonly AppSettings _settings;
private readonly IFileClipboard _clipboard;
private readonly DispatcherQueue _ui;
private CancellationTokenSource? _navigationCts;
private CancellationTokenSource? _searchCts;
private readonly List _allItems = [];
private readonly Dictionary _recycleOriginalPaths = new(StringComparer.OrdinalIgnoreCase);
private int _progressiveCount;
public ExplorerPaneViewModel(
IFileSystemService fs,
SearchService search,
IFileOperationService operations,
ItemVisualService visuals,
IFileClipboard clipboard,
AppSettings settings,
DispatcherQueue ui)
{
_fs = fs;
_search = search;
_operations = operations;
_visuals = visuals;
_clipboard = clipboard;
_settings = settings;
_ui = ui;
_clipboard.Changed += (_, _) => PasteCommand.NotifyCanExecuteChanged();
_operations.UndoStackChanged += (_, _) =>
{
UndoCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(UndoDescription));
};
_operations.JobsChanged += (_, _) =>
{
UndoCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(UndoDescription));
};
_viewMode = _settings.GetFolderView(KnownFolders.HomeParsingName).ViewMode;
var state = _settings.GetFolderView(KnownFolders.HomeParsingName);
_sort = new SortSpec(state.SortColumn, state.SortDirection);
}
// ── 位置状态 ────────────────────────────────────────────────────────────
[ObservableProperty] private NavigationLocation _location = NavigationLocation.Home;
[ObservableProperty] private string _currentPath = string.Empty;
[ObservableProperty] private string _title = "主页";
[ObservableProperty] private string _glyph = "\uE80F";
[ObservableProperty] private string _addressText = string.Empty;
[ObservableProperty] private ObservableCollection _breadcrumbs = [];
// ── 内容状态 ────────────────────────────────────────────────────────────
[ObservableProperty] private ObservableCollection _items = [];
[ObservableProperty] private ObservableCollection _searchResults = [];
[ObservableProperty] private bool _isLoading;
[ObservableProperty] private bool _isSearchActive;
[ObservableProperty] private bool _isSearching;
[ObservableProperty] private string _searchText = string.Empty;
[ObservableProperty] private string _searchStatusText = string.Empty;
[ObservableProperty] private string _statusText = "就绪";
[ObservableProperty] private string _selectionText = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
private string? _errorMessage;
[ObservableProperty] private string? _emptyMessage;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasNote))]
private string? _noteMessage;
[ObservableProperty] private bool _isRecycleBin;
[ObservableProperty] private bool _isGallery;
/// 是否显示左侧导航窗格(双窗格时只有主窗格显示)。
[ObservableProperty] private bool _showSidebar = true;
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public bool HasNote => !string.IsNullOrEmpty(NoteMessage);
// ── 视图状态 ────────────────────────────────────────────────────────────
[ObservableProperty] private ViewMode _viewMode = ViewMode.Details;
[ObservableProperty] private SortSpec _sort = SortSpec.Default;
[ObservableProperty] private GroupBy _groupBy = GroupBy.None;
[ObservableProperty] private bool _showHidden;
public NavigationHistory History { get; } = new();
public ItemVisualService Visuals => _visuals;
public bool ShowSystemFiles => _settings.ShowSystemFiles;
public string? UndoDescription => _operations.CanUndo ? _operations.UndoDescription : null;
/// 网格视图的单元格尺寸(图标 + 一行留白,与资源管理器一致的紧凑排布)。
public double TileWidth => IconSize + 36;
public double TileHeight => IconSize + 46;
/// 详情/列表视图的行高。
public double RowHeight => ViewMode == ViewMode.List ? 24 : 30;
public event EventHandler? ErrorOccurred;
public event EventHandler? RenameRequested;
public event EventHandler? ScrollIntoViewRequested;
public event EventHandler? OpenInNewTabRequested;
/// 请求把焦点放进搜索框(Ctrl+F / Ctrl+E)。
public event EventHandler? FocusSearchRequested;
/// 供视图调用:把焦点放进搜索框。
public void RequestSearchFocus() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
/// 把错误显示在本窗格顶部的 InfoBar 里(不打断操作)。
public void ReportError(string message) => ErrorMessage = message;
public void DismissError() => ErrorMessage = null;
/// 点击地址栏上的"复制地址"等动作时使用。
public void FocusAddressBar() => AddressBarFocusRequested?.Invoke(this, EventArgs.Empty);
public event EventHandler? AddressBarFocusRequested;
// ── 导航 ────────────────────────────────────────────────────────────────
public Task NavigateAsync(NavigationLocation location, bool addToHistory = true)
{
_navigationCts?.Cancel();
var cts = new CancellationTokenSource();
_navigationCts = cts;
return NavigateCoreAsync(location, addToHistory, cts.Token);
}
private async Task NavigateCoreAsync(NavigationLocation location, bool addToHistory, CancellationToken ct)
{
Location = location;
Title = location.DisplayName;
Glyph = location.Glyph;
IsRecycleBin = location.Kind == LocationKind.RecycleBin;
IsGallery = location.Kind == LocationKind.Gallery;
ErrorMessage = null;
NoteMessage = null;
IsSearchActive = false;
SearchResults = [];
SearchText = string.Empty;
_recycleOriginalPaths.Clear();
_visuals.ResetPending();
if (addToHistory) History.Push(location);
BackCommand.NotifyCanExecuteChanged();
ForwardCommand.NotifyCanExecuteChanged();
UpCommand.NotifyCanExecuteChanged();
var viewState = _settings.GetFolderView(location.Path);
if (addToHistory)
{
ViewMode = viewState.ViewMode;
Sort = new SortSpec(viewState.SortColumn, viewState.SortDirection);
GroupBy = viewState.GroupBy;
}
if (location.IsVirtual)
{
CurrentPath = location.Path;
AddressText = location.DisplayName == "此电脑" && location.Kind == LocationKind.ThisPc
? "此电脑"
: location.DisplayName;
BuildBreadcrumbs(location);
await LoadVirtualAsync(location, ct).ConfigureAwait(true);
return;
}
CurrentPath = location.Path;
AddressText = location.Path;
BuildBreadcrumbs(location);
await LoadFolderAsync(location.Path, ct).ConfigureAwait(true);
}
private async Task LoadFolderAsync(string path, CancellationToken ct)
{
IsLoading = true;
_allItems.Clear();
_progressiveCount = 0;
var display = new ObservableCollection();
Items = display;
EmptyMessage = null;
var listing = new FolderListing { Path = path };
var sw = Stopwatch.StartNew();
try
{
await _fs.EnumerateAsync(path, listing, async batch =>
{
if (ct.IsCancellationRequested) return;
var mapped = new List(batch.Count);
foreach (var entry in batch)
{
if (entry.IsHidden && !_settings.ShowHiddenFiles) continue;
if (entry.IsSystem && !_settings.ShowSystemFiles) continue;
var item = new ExplorerItem(entry) { DisplayName = BuildDisplayName(entry) };
mapped.Add(item);
}
if (mapped.Count == 0) return;
await _ui.EnqueueAsync(() =>
{
if (ct.IsCancellationRequested) return;
foreach (var item in mapped)
{
_allItems.Add(item);
display.Add(item);
}
_progressiveCount = display.Count;
IsLoading = false;
UpdateStatusText();
}).ConfigureAwait(false);
}, 256, ct).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
return;
}
if (ct.IsCancellationRequested) return;
EmptyMessage = _allItems.Count == 0 ? "此文件夹为空" : null;
ErrorMessage = listing.Error;
IsLoading = false;
ApplySortAndRefresh();
UpdateStatusText();
// 为前若干行预取图标(后面的由界面按可见性按需请求)
PrimeIcons(120);
sw.Stop();
App.Log($"[nav] {path}: {_allItems.Count} 项, {sw.ElapsedMilliseconds} ms");
// 记录"最近使用"
RecordRecent(path);
}
private async Task LoadVirtualAsync(NavigationLocation location, CancellationToken ct)
{
IsLoading = true;
_allItems.Clear();
Items = [];
EmptyMessage = null;
switch (location.Kind)
{
case LocationKind.ThisPc:
await LoadDrivesAsync(ct).ConfigureAwait(true);
break;
case LocationKind.RecycleBin:
await LoadRecycleBinAsync(ct).ConfigureAwait(true);
break;
case LocationKind.Gallery:
await LoadGalleryAsync(ct).ConfigureAwait(true);
break;
case LocationKind.Network:
foreach (var drive in DriveItem.Enumerate().Where(d => d.DriveType == 4))
_allItems.Add(MakeFolderItem(drive.RootPath, drive.DisplayName));
EmptyMessage = _allItems.Count == 0 ? "没有已映射的网络位置。可在资源管理器中映射网络驱动器后再回来查看。" : null;
break;
case LocationKind.Home:
default:
LoadHome();
break;
}
IsLoading = false;
ApplySortAndRefresh();
UpdateStatusText();
PrimeIcons(80);
}
private Task LoadDrivesAsync(CancellationToken ct)
{
foreach (var drive in DriveItem.Enumerate())
{
ct.ThrowIfCancellationRequested();
var item = new ExplorerItem(new FileEntry
{
Name = drive.DisplayName,
FullPath = drive.RootPath,
IsDirectory = true,
ModifiedUtc = DateTime.UtcNow,
Attributes = FileAttributes.Directory
})
{ DisplayName = drive.DisplayName };
_allItems.Add(item);
}
EmptyMessage = _allItems.Count == 0 ? "没有可用的驱动器" : null;
return Task.CompletedTask;
}
private async Task LoadRecycleBinAsync(CancellationToken ct)
{
var entries = await RecycleBinView.EnumerateAsync(ct).ConfigureAwait(true);
foreach (var entry in entries)
{
if (ct.IsCancellationRequested) return;
bool isDir;
try { isDir = Directory.Exists(entry.RecyclePath); }
catch { isDir = false; }
_recycleOriginalPaths[entry.RecyclePath] = entry.OriginalPath;
var item = new ExplorerItem(new FileEntry
{
Name = entry.Name,
FullPath = entry.RecyclePath,
IsDirectory = isDir,
Size = isDir ? 0 : entry.Size,
ModifiedUtc = entry.DeletedUtc == DateTime.MinValue ? DateTime.UtcNow : entry.DeletedUtc,
CreatedUtc = entry.DeletedUtc,
Attributes = isDir ? FileAttributes.Directory : FileAttributes.Normal
})
{ DisplayName = entry.Name };
_allItems.Add(item);
}
EmptyMessage = _allItems.Count == 0 ? "回收站是空的" : null;
RestoreSelectedCommand.NotifyCanExecuteChanged();
}
private async Task LoadGalleryAsync(CancellationToken ct)
{
var query = SearchQueryParser.Parse("*");
query.Extensions.AddRange(["jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "tif", "tiff", "avif", "mp4", "mov", "mkv"]);
var outcome = await _search.SearchAsync(query, SearchScope.Global, null, 3000, ct).ConfigureAwait(true);
foreach (var hit in outcome.Hits)
{
if (ct.IsCancellationRequested) return;
_allItems.Add(new ExplorerItem(new FileEntry
{
Name = hit.Name,
FullPath = hit.Path,
IsDirectory = false,
Size = hit.Size,
ModifiedUtc = hit.ModifiedUtc == DateTime.MinValue ? DateTime.UtcNow : hit.ModifiedUtc,
Attributes = FileAttributes.Normal
})
{ DisplayName = BuildDisplayName(new FileEntry { Name = hit.Name, FullPath = hit.Path }) });
}
Sort = new SortSpec(SortColumn.DateModified, SortDirection.Descending);
if (_allItems.Count == 0)
EmptyMessage = outcome.UsedIndex ? "没有找到图片或视频" : "图库需要 NTFS 索引才能即时显示(可在设置中开启索引)";
}
private void LoadHome()
{
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var path in _settings.PinnedFolders.Concat(_settings.RecentFolders))
{
if (string.IsNullOrWhiteSpace(path) || !seen.Add(path)) continue;
if (!SafeDirectoryExists(path)) continue;
_allItems.Add(MakeFolderItem(path, PathHelper.GetName(path)));
}
if (_allItems.Count == 0)
{
foreach (var path in new[] { KnownFolders.Desktop, KnownFolders.Downloads, KnownFolders.Documents, KnownFolders.Pictures })
{
if (string.IsNullOrWhiteSpace(path) || !SafeDirectoryExists(path)) continue;
_allItems.Add(MakeFolderItem(path, PathHelper.GetName(path)));
}
EmptyMessage = null;
}
}
private static bool SafeDirectoryExists(string path)
{
try { return Directory.Exists(path); } catch { return false; }
}
private static ExplorerItem MakeFolderItem(string path, string displayName)
=> new(new FileEntry
{
Name = displayName,
FullPath = path,
IsDirectory = true,
ModifiedUtc = DateTime.UtcNow,
Attributes = FileAttributes.Directory
})
{ DisplayName = displayName };
private void RecordRecent(string path)
{
var recent = _settings.RecentFolders;
recent.RemoveAll(p => string.Equals(p, path, StringComparison.OrdinalIgnoreCase));
recent.Insert(0, path);
if (recent.Count > 20) recent.RemoveRange(20, recent.Count - 20);
}
// ── 面包屑 ──────────────────────────────────────────────────────────────
private void BuildBreadcrumbs(NavigationLocation location)
{
var segments = new List();
if (location.IsVirtual)
{
var parent = location.Kind switch
{
LocationKind.ThisPc or LocationKind.RecycleBin or LocationKind.Network => NavigationLocation.ThisPc,
_ => null
};
if (parent is not null) segments.Add(new BreadcrumbSegment(parent.DisplayName, parent.Path, false));
segments.Add(new BreadcrumbSegment(location.DisplayName, location.Path, false));
Breadcrumbs = new ObservableCollection(segments);
return;
}
var path = PathHelper.NormalizeDisplay(location.Path);
var root = Path.GetPathRoot(path) ?? path;
segments.Add(new BreadcrumbSegment("此电脑", KnownFolders.ThisPcParsingName, false));
var driveName = root.TrimEnd('\\');
try
{
var drive = new DriveInfo(root);
var label = drive.IsReady ? drive.VolumeLabel : string.Empty;
driveName = string.IsNullOrWhiteSpace(label) ? $"本地磁盘 ({root.TrimEnd('\\')})" : $"{label} ({root.TrimEnd('\\')})";
}
catch
{
// 使用默认显示名
}
segments.Add(new BreadcrumbSegment(driveName, root, true));
var remainder = path[root.Length..].Trim('\\');
if (remainder.Length > 0)
{
var accumulated = root;
foreach (var part in remainder.Split(['\\'], StringSplitOptions.RemoveEmptyEntries))
{
accumulated = Path.Combine(accumulated, part);
segments.Add(new BreadcrumbSegment(part, accumulated, false));
}
}
Breadcrumbs = new ObservableCollection(segments);
}
// ── 排序 / 过滤 ─────────────────────────────────────────────────────────
public void ApplySortAndRefresh()
{
var spec = Sort;
var dir = spec.Direction == SortDirection.Ascending ? 1 : -1;
var comparer = BuildComparer(spec.Column);
_allItems.Sort((a, b) =>
{
// 资源管理器行为:文件夹始终排在文件前面(按名称/类型排序时)
if (spec.Column is SortColumn.Name or SortColumn.Type)
{
if (a.IsDirectory != b.IsDirectory) return a.IsDirectory ? -1 : 1;
}
var result = comparer(a, b);
if (result == 0) result = NaturalStringComparer.Instance.Compare(a.Name, b.Name);
return result * dir;
});
var refreshed = new ObservableCollection(_allItems);
Items = refreshed;
UpdateStatusText();
}
private static Comparison BuildComparer(SortColumn column) => column switch
{
SortColumn.Name => (a, b) => NaturalStringComparer.Instance.Compare(a.DisplayName, b.DisplayName),
SortColumn.DateModified => (a, b) => a.Entry.ModifiedUtc.CompareTo(b.Entry.ModifiedUtc),
SortColumn.DateCreated => (a, b) => a.Entry.CreatedUtc.CompareTo(b.Entry.CreatedUtc),
SortColumn.Size => (a, b) => a.Size.CompareTo(b.Size),
SortColumn.Type => (a, b) => string.Compare(a.TypeText, b.TypeText, StringComparison.CurrentCultureIgnoreCase),
SortColumn.Extension => (a, b) => string.Compare(a.Extension, b.Extension, StringComparison.OrdinalIgnoreCase),
SortColumn.Path => (a, b) => string.Compare(a.FullPath, b.FullPath, StringComparison.OrdinalIgnoreCase),
SortColumn.Attributes => (a, b) => ((int)a.Entry.Attributes).CompareTo((int)b.Entry.Attributes),
_ => (a, b) => NaturalStringComparer.Instance.Compare(a.DisplayName, b.DisplayName)
};
private string BuildDisplayName(FileEntry entry)
{
if (entry.IsDirectory || _settings.ShowFileExtensions) return entry.Name;
var idx = entry.Name.LastIndexOf('.');
return idx > 0 ? entry.Name[..idx] : entry.Name;
}
public void RebuildDisplayNames()
{
foreach (var item in _allItems) item.DisplayName = BuildDisplayName(item.Entry);
}
private void PrimeIcons(int count)
{
var take = Math.Min(count, Items.Count);
for (var i = 0; i < take; i++) _visuals.RequestIcon(Items[i]);
}
// ── 命令 ────────────────────────────────────────────────────────────────
private bool CanGoBack() => History.CanGoBack;
private bool CanGoForward() => History.CanGoForward;
private bool CanGoUp() => !string.IsNullOrEmpty(CurrentPath) && Location.Kind is LocationKind.Folder or LocationKind.Drive;
[RelayCommand(CanExecute = nameof(CanGoBack))]
private async Task BackAsync()
{
var target = History.Back();
if (target is null) return;
await NavigateAsync(target, addToHistory: false);
}
[RelayCommand(CanExecute = nameof(CanGoForward))]
private async Task ForwardAsync()
{
var target = History.Forward();
if (target is null) return;
await NavigateAsync(target, addToHistory: false);
}
[RelayCommand(CanExecute = nameof(CanGoUp))]
private async Task UpAsync()
{
var parent = PathHelper.GetParent(CurrentPath);
if (string.Equals(parent, CurrentPath, StringComparison.OrdinalIgnoreCase)) return;
await NavigateAsync(NavigationLocation.FromPath(parent, LocationKind.Folder));
}
[RelayCommand]
private async Task RefreshAsync()
{
if (Location.IsVirtual) await NavigateAsync(Location, addToHistory: false);
else await LoadFolderAsync(CurrentPath, CancellationToken.None);
}
[RelayCommand]
private async Task NavigatePathAsync(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return;
path = path.Trim().Trim('"');
if (path.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || path.StartsWith("::", StringComparison.Ordinal))
{
await NavigateAsync(new NavigationLocation(LocationKind.Folder, path, path, "\uE8B7"));
return;
}
if (SafeDirectoryExists(path))
{
await NavigateAsync(NavigationLocation.FromPath(path));
return;
}
ErrorOccurred?.Invoke(this, $"找不到路径:{path}");
AddressText = CurrentPath;
}
[RelayCommand]
public async Task OpenItemAsync(ExplorerItem? item)
{
if (item is null) return;
if (item.IsDirectory)
{
if (_settings.OpenFoldersInNewTab)
{
OpenInNewTabRequested?.Invoke(this, NavigationLocation.FromPath(item.FullPath));
return;
}
await NavigateAsync(NavigationLocation.FromPath(item.FullPath));
return;
}
if (!ShellActions.Open(item.FullPath))
ErrorOccurred?.Invoke(this, $"无法打开:{item.FullPath}");
}
[RelayCommand]
private void OpenInNewTab(ExplorerItem? item)
{
if (item is null || !item.IsDirectory) return;
OpenInNewTabRequested?.Invoke(this, NavigationLocation.FromPath(item.FullPath));
}
[RelayCommand]
private async Task NewFolderAsync()
{
if (Location.Kind is not (LocationKind.Folder or LocationKind.Drive)) return;
var name = "新建文件夹";
var path = Path.Combine(CurrentPath, name);
var suffix = 2;
while (SafeDirectoryExists(path)) path = Path.Combine(CurrentPath, $"{name} ({suffix++})");
try
{
Directory.CreateDirectory(path);
}
catch (Exception ex)
{
ErrorOccurred?.Invoke(this, $"无法新建文件夹:{ex.Message}");
return;
}
await RefreshAsync();
var created = _allItems.FirstOrDefault(i => string.Equals(i.FullPath, path, StringComparison.OrdinalIgnoreCase));
if (created is not null)
{
created.IsPending = true;
ScrollIntoViewRequested?.Invoke(this, created);
StartRename(created);
}
}
[RelayCommand]
private void StartRename(ExplorerItem? item)
{
if (item is null) return;
foreach (var other in _allItems) other.IsRenaming = false;
item.RenameText = item.DisplayName;
item.IsRenaming = true;
RenameRequested?.Invoke(this, item);
}
[RelayCommand]
private async Task CommitRenameAsync(ExplorerItem? item)
{
if (item is null || !item.IsRenaming) return;
var newName = item.RenameText.Trim();
item.IsRenaming = false;
if (string.IsNullOrWhiteSpace(newName) || newName == item.DisplayName)
{
if (item.IsPending) await RefreshAsync();
return;
}
if (newName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
ErrorOccurred?.Invoke(this, "名称中不能包含下列字符:\\ / : * ? \" < > |");
return;
}
if (item.IsPending)
{
// 新建的占位项:直接重命名(并允许改名后自动补扩展名)
try
{
var target = Path.Combine(PathHelper.GetParent(item.FullPath), newName);
if (SafeDirectoryExists(item.FullPath)) Directory.Move(item.FullPath, target);
else File.Move(item.FullPath, target);
}
catch (Exception ex)
{
ErrorOccurred?.Invoke(this, $"重命名失败:{ex.Message}");
}
await RefreshAsync();
return;
}
_operations.EnqueueRename(item.FullPath, newName);
await Task.Delay(180);
await RefreshAsync();
}
[RelayCommand]
private void CancelRename(ExplorerItem? item)
{
if (item is null) return;
item.IsRenaming = false;
if (item.IsPending) _ = RefreshAsync();
}
[RelayCommand]
private void CopySelected(IReadOnlyList? items)
{
var paths = SelectedPaths(items);
if (paths.Count == 0) return;
_clipboard.Set(paths, cut: false);
ShellActions.SetClipboardFiles(paths, cut: false);
UpdateStatusText();
}
[RelayCommand]
private void CutSelected(IReadOnlyList? items)
{
var paths = SelectedPaths(items);
if (paths.Count == 0) return;
_clipboard.Set(paths, cut: true);
ShellActions.SetClipboardFiles(paths, cut: true);
UpdateStatusText();
}
private bool CanPaste() => _clipboard.Paths.Count > 0 && Location.Kind is LocationKind.Folder or LocationKind.Drive;
[RelayCommand(CanExecute = nameof(CanPaste))]
private async Task PasteAsync()
{
var paths = _clipboard.Paths;
if (paths.Count == 0) return;
var destination = CurrentPath;
if (_clipboard.IsCut) _operations.EnqueueMove(paths, destination);
else _operations.EnqueueCopy(paths, destination);
// 等待作业真正开始并落盘后再刷新(作业是异步的,这里给一个短暂延迟并依赖队列事件)
await Task.Delay(400);
await RefreshAsync();
}
[RelayCommand]
private void CopyPathSelected(IReadOnlyList? items)
{
var paths = SelectedPaths(items);
if (paths.Count == 0) return;
var package = new Windows.ApplicationModel.DataTransfer.DataPackage();
package.SetText(string.Join(Environment.NewLine, paths));
Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package);
UpdateStatusText("已复制路径");
}
private bool CanUndo() => _operations.CanUndo;
[RelayCommand(CanExecute = nameof(CanUndo))]
private async Task UndoAsync()
{
var result = await _operations.UndoAsync();
if (!result.Success) ErrorOccurred?.Invoke(this, result.Error ?? "撤销失败");
await RefreshAsync();
}
[RelayCommand]
private async Task DeleteSelectedAsync(IReadOnlyList? items)
{
var paths = SelectedPaths(items);
if (paths.Count == 0) return;
if (IsRecycleBin) { await DeleteFromRecycleBinAsync(paths); return; }
_operations.EnqueueDelete(paths, permanent: !_settings.DeleteToRecycleBin);
await Task.Delay(300);
await RefreshAsync();
}
[RelayCommand]
private async Task PermanentDeleteSelectedAsync(IReadOnlyList? items)
{
var paths = SelectedPaths(items);
if (paths.Count == 0) return;
if (IsRecycleBin) { await DeleteFromRecycleBinAsync(paths); return; }
_operations.EnqueueDelete(paths, permanent: true);
await Task.Delay(300);
await RefreshAsync();
}
private async Task DeleteFromRecycleBinAsync(IReadOnlyList recyclePaths)
{
foreach (var path in recyclePaths)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
else if (File.Exists(path)) File.Delete(path);
var meta = Path.Combine(Path.GetDirectoryName(path)!, "$I" + Path.GetFileName(path)[2..]);
if (File.Exists(meta)) File.Delete(meta);
}
catch (Exception ex)
{
ErrorOccurred?.Invoke(this, $"无法彻底删除:{ex.Message}");
}
}
await RefreshAsync();
}
private bool CanRestore() => IsRecycleBin;
[RelayCommand(CanExecute = nameof(CanRestore))]
private async Task RestoreSelectedAsync(IReadOnlyList? items)
{
var paths = SelectedPaths(items);
if (paths.Count == 0) return;
var moves = new List<(string From, string To)>();
foreach (var path in paths)
{
if (!_recycleOriginalPaths.TryGetValue(path, out var original)) continue;
moves.Add((path, original));
}
if (moves.Count == 0) return;
await Task.Run(() =>
{
foreach (var (from, to) in moves)
{
try
{
var dir = Path.GetDirectoryName(to);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
if (Directory.Exists(from)) Directory.Move(from, to);
else File.Move(from, to, overwrite: false);
}
catch (Exception ex)
{
ErrorOccurred?.Invoke(this, $"还原失败({Path.GetFileName(to)}):{ex.Message}");
}
}
});
await RefreshAsync();
}
[RelayCommand]
private async Task EmptyRecycleBinAsync()
{
if (!IsRecycleBin) return;
var paths = _allItems.Select(i => i.FullPath).ToList();
await DeleteFromRecycleBinAsync(paths);
UpdateStatusText("回收站已清空");
}
[RelayCommand]
private void OpenSelected(IReadOnlyList? items)
{
var first = items?.FirstOrDefault();
if (first is not null) _ = OpenItemAsync(first);
}
[RelayCommand]
private void ShowPropertiesSelected(IReadOnlyList? items)
{
var paths = SelectedPaths(items);
if (paths.Count == 0) return;
PropertiesRequested?.Invoke(this, paths);
}
public event EventHandler>? PropertiesRequested;
[RelayCommand]
private void RevealSelected(IReadOnlyList? items)
{
var first = items?.FirstOrDefault();
if (first is not null) ShellActions.RevealInExplorer(first.FullPath);
}
[RelayCommand]
private void OpenWithSelected(IReadOnlyList? items)
{
var first = items?.FirstOrDefault();
if (first is not null && !first.IsDirectory) ShellActions.OpenWith(first.FullPath);
}
[RelayCommand]
private void SortBy(SortColumn column)
{
Sort = Sort.Toggle(column);
PersistViewState();
ApplySortAndRefresh();
}
[RelayCommand]
private void SetViewMode(string? mode)
{
if (string.IsNullOrEmpty(mode)) return;
ViewMode = mode switch
{
"ExtraLargeIcons" => ViewMode.ExtraLargeIcons,
"LargeIcons" => ViewMode.LargeIcons,
"MediumIcons" => ViewMode.MediumIcons,
"SmallIcons" => ViewMode.SmallIcons,
"List" => ViewMode.List,
"Tiles" => ViewMode.Tiles,
"Content" => ViewMode.Content,
_ => ViewMode.Details
};
PersistViewState();
OnPropertyChanged(nameof(IsDetailsView));
OnPropertyChanged(nameof(IsIconsView));
OnPropertyChanged(nameof(IconSize));
OnPropertyChanged(nameof(TileWidth));
OnPropertyChanged(nameof(TileHeight));
OnPropertyChanged(nameof(RowHeight));
PrimeIcons(160);
}
[RelayCommand]
private void CycleViewMode()
{
var order = new[] { ViewMode.ExtraLargeIcons, ViewMode.LargeIcons, ViewMode.MediumIcons, ViewMode.SmallIcons, ViewMode.List, ViewMode.Details };
var index = Array.IndexOf(order, ViewMode);
SetViewMode(order[(index + 1) % order.Length].ToString());
}
[RelayCommand]
private void ToggleHidden()
{
_settings.ShowHiddenFiles = !_settings.ShowHiddenFiles;
ShowHidden = _settings.ShowHiddenFiles;
SettingsChanged?.Invoke(this, EventArgs.Empty);
_ = RefreshAsync();
}
public event EventHandler? SettingsChanged;
public bool IsDetailsView => ViewMode == ViewMode.Details;
public bool IsIconsView => ViewMode is ViewMode.ExtraLargeIcons or ViewMode.LargeIcons or ViewMode.MediumIcons or ViewMode.SmallIcons or ViewMode.Tiles;
public int IconSize => ViewMode switch
{
ViewMode.ExtraLargeIcons => 256,
ViewMode.LargeIcons => 96,
ViewMode.MediumIcons => 48,
ViewMode.SmallIcons => 20,
ViewMode.Tiles => 64,
ViewMode.Content => 32,
_ => 16
};
private void PersistViewState()
{
var state = _settings.GetFolderView(Location.Path);
state.ViewMode = ViewMode;
state.SortColumn = Sort.Column;
state.SortDirection = Sort.Direction;
state.GroupBy = GroupBy;
_settings.Save();
}
// ── 搜索 ────────────────────────────────────────────────────────────────
public async Task SearchAsync(string? text)
{
SearchText = text ?? string.Empty;
_searchCts?.Cancel();
if (string.IsNullOrWhiteSpace(SearchText))
{
IsSearchActive = false;
SearchResults = [];
SearchStatusText = string.Empty;
_navigationCts?.Cancel();
await RefreshAsync();
return;
}
var cts = new CancellationTokenSource();
_searchCts = cts;
try
{
await Task.Delay(110, cts.Token);
}
catch (OperationCanceledException)
{
return;
}
IsSearching = true;
IsSearchActive = true;
var query = SearchQueryParser.Parse(SearchText);
var scope = _settings.SearchScope;
// 即使选了"全盘",也把当前文件夹传下去:索引不可用时它是实时扫描的起点(否则会从盘根扫)
var basePath = Location.Kind is LocationKind.Folder or LocationKind.Drive ? CurrentPath : null;
try
{
var outcome = await _search.SearchAsync(query, scope, basePath, 4000, cts.Token);
if (cts.IsCancellationRequested) return;
var collection = new ObservableCollection();
var count = 0;
foreach (var hit in outcome.Hits)
{
collection.Add(new SearchResultItem(hit));
if (++count % 400 == 0) await Task.Yield();
}
SearchResults = collection;
var source = outcome.UsedIndex ? "NTFS 索引" : "实时扫描";
var suffix = outcome.Truncated ? "(结果已截断)" : string.Empty;
SearchStatusText = $"{outcome.Hits.Count:N0} 项结果 · {outcome.Elapsed.TotalMilliseconds:0} 毫秒 · {source}{suffix}";
NoteMessage = outcome.Note;
PrimeSearchIcons(60);
}
catch (OperationCanceledException)
{
// 输入变化时正常取消
}
finally
{
IsSearching = false;
}
}
[RelayCommand]
private Task ClearSearchAsync() => SearchAsync(string.Empty);
[RelayCommand]
public async Task OpenSearchResultAsync(SearchResultItem? item)
{
if (item is null) return;
if (item.IsDirectory)
{
await NavigateAsync(NavigationLocation.FromPath(item.FullPath));
return;
}
if (!ShellActions.Open(item.FullPath))
ErrorOccurred?.Invoke(this, $"无法打开:{item.FullPath}");
}
[RelayCommand]
private async Task OpenSearchResultFolderAsync(SearchResultItem? item)
{
if (item is null) return;
var dir = item.IsDirectory ? item.FullPath : item.Directory;
if (SafeDirectoryExists(dir)) await NavigateAsync(NavigationLocation.FromPath(dir));
}
private void PrimeSearchIcons(int count)
{
var take = Math.Min(count, SearchResults.Count);
for (var i = 0; i < take; i++) _visuals.RequestIcon(SearchResults[i]);
}
// ── 拖放 / 批量移动 ─────────────────────────────────────────────────────
/// 把条目拖放到某个文件夹(同盘为移动,按住 Ctrl 为复制)。
public async Task DropIntoAsync(IReadOnlyList paths, string destination, bool copy)
{
if (paths.Count == 0) return;
if (copy) _operations.EnqueueCopy(paths, destination);
else _operations.EnqueueMove(paths, destination);
await Task.Delay(300);
await RefreshAsync();
}
// ── 选择 / 状态栏 ───────────────────────────────────────────────────────
public void OnSelectionChanged(IReadOnlyList selected)
{
foreach (var item in _allItems) item.IsSelected = false;
foreach (var item in selected) item.IsSelected = true;
UpdateSelectionText(selected);
RestoreSelectedCommand.NotifyCanExecuteChanged();
}
public int SelectionCount { get; private set; }
private void UpdateSelectionText(IReadOnlyList selected)
{
SelectionCount = selected.Count;
if (selected.Count == 0)
{
SelectionText = string.Empty;
UpdateStatusText();
return;
}
long bytes = 0;
var unknown = false;
foreach (var item in selected)
{
if (item.IsDirectory) { unknown = true; continue; }
bytes += item.Size;
}
var sizeText = bytes > 0 ? FileEntry.FormatSize(bytes) : null;
SelectionText = unknown
? $"已选择 {selected.Count} 个项目"
: sizeText is null ? $"已选择 {selected.Count} 个项目" : $"已选择 {selected.Count} 个项目 · {sizeText}";
StatusText = Items.Count == 0 ? SelectionText : $"{Items.Count} 个项目 {SelectionText}";
}
private void UpdateStatusText(string? overrideText = null)
{
if (overrideText is not null)
{
StatusText = overrideText;
return;
}
StatusText = Items.Count == 0 ? "此文件夹为空" : $"{Items.Count} 个项目";
if (!string.IsNullOrEmpty(SelectionText) && Items.Count > 0) StatusText += $" {SelectionText}";
}
private static List SelectedPaths(IReadOnlyList? items)
=> items is null ? [] : items.Where(i => i is not null).Select(i => i.FullPath).ToList();
public void Dispose()
{
_navigationCts?.Cancel();
_searchCts?.Cancel();
}
}