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(); } }