|
|
|
@ -14,6 +14,7 @@ using System.IO; |
|
|
|
using System.Linq; |
|
|
|
using System.Reactive; |
|
|
|
using System.Reactive.Linq; |
|
|
|
using System.Threading; |
|
|
|
using System.Threading.Tasks; |
|
|
|
|
|
|
|
namespace AuroraDesk.Presentation.ViewModels.Pages; |
|
|
|
@ -38,6 +39,8 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
private string _selectedHighlightLanguage = AutoDetectLanguageOption; |
|
|
|
private string? _detectedHighlightLanguage; |
|
|
|
private string _searchQuery = string.Empty; |
|
|
|
private CancellationTokenSource? _filterCancellationSource; |
|
|
|
private readonly object _filterLock = new(); |
|
|
|
|
|
|
|
public FileExplorerPageViewModel( |
|
|
|
IScreen hostScreen, |
|
|
|
@ -57,7 +60,11 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
BrowseDirectoryCommand = ReactiveCommand.CreateFromTask(BrowseDirectoryAsync); |
|
|
|
RefreshDirectoryCommand = ReactiveCommand.CreateFromTask(RefreshCurrentDirectoryAsync, canRefresh); |
|
|
|
|
|
|
|
ApplyFilter(); |
|
|
|
// 设置搜索防抖:延迟 400ms 执行过滤
|
|
|
|
this.WhenAnyValue(x => x.SearchQuery) |
|
|
|
.Throttle(TimeSpan.FromMilliseconds(400)) |
|
|
|
.ObserveOn(RxApp.MainThreadScheduler) |
|
|
|
.Subscribe(_ => ApplyFilterAsync()); |
|
|
|
} |
|
|
|
|
|
|
|
public ObservableCollection<FileTreeNode> TreeItems => _treeItems; |
|
|
|
@ -77,7 +84,7 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
} |
|
|
|
|
|
|
|
this.RaiseAndSetIfChanged(ref _searchQuery, value); |
|
|
|
ApplyFilter(); |
|
|
|
// 过滤操作已通过 Throttle 自动触发,这里不需要手动调用
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -243,7 +250,7 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
} |
|
|
|
|
|
|
|
TotalFileCount = result.FileCount; |
|
|
|
ApplyFilter(); |
|
|
|
ApplyFilterAsync(); |
|
|
|
}); |
|
|
|
|
|
|
|
StatusMessage = result.FileCount == 0 |
|
|
|
@ -368,7 +375,7 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
|
|
|
|
var orderedChildren = children |
|
|
|
.OrderByDescending(c => c.IsDirectory) |
|
|
|
.ThenBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase) |
|
|
|
.ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase) |
|
|
|
.ToList(); |
|
|
|
|
|
|
|
foreach (var child in orderedChildren) |
|
|
|
@ -445,44 +452,127 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
return PlainTextLanguage; |
|
|
|
} |
|
|
|
|
|
|
|
private void ApplyFilter() |
|
|
|
private async void ApplyFilterAsync() |
|
|
|
{ |
|
|
|
// 取消之前的过滤操作
|
|
|
|
lock (_filterLock) |
|
|
|
{ |
|
|
|
_filterCancellationSource?.Cancel(); |
|
|
|
_filterCancellationSource = new CancellationTokenSource(); |
|
|
|
} |
|
|
|
|
|
|
|
var cancellationToken = _filterCancellationSource.Token; |
|
|
|
var keyword = SearchQuery?.Trim(); |
|
|
|
var useFilter = !string.IsNullOrWhiteSpace(keyword); |
|
|
|
|
|
|
|
_filteredTreeItems.Clear(); |
|
|
|
try |
|
|
|
{ |
|
|
|
// 在后台线程执行过滤操作
|
|
|
|
var filteredItems = await Task.Run(() => |
|
|
|
{ |
|
|
|
if (cancellationToken.IsCancellationRequested) |
|
|
|
return null; |
|
|
|
|
|
|
|
if (!useFilter) |
|
|
|
{ |
|
|
|
// 无过滤时,直接返回原始树
|
|
|
|
return _treeItems.ToList(); |
|
|
|
} |
|
|
|
|
|
|
|
// 执行过滤
|
|
|
|
var result = new List<FileTreeNode>(); |
|
|
|
foreach (var item in _treeItems) |
|
|
|
{ |
|
|
|
if (cancellationToken.IsCancellationRequested) |
|
|
|
return null; |
|
|
|
|
|
|
|
var filteredNode = CloneNodeWithFilter(item, keyword!, cancellationToken); |
|
|
|
if (filteredNode != null) |
|
|
|
{ |
|
|
|
result.Add(filteredNode); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return result; |
|
|
|
}, cancellationToken); |
|
|
|
|
|
|
|
if (!useFilter) |
|
|
|
if (cancellationToken.IsCancellationRequested || filteredItems == null) |
|
|
|
{ |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 在 UI 线程更新结果
|
|
|
|
await Dispatcher.UIThread.InvokeAsync(() => |
|
|
|
{ |
|
|
|
if (cancellationToken.IsCancellationRequested) |
|
|
|
return; |
|
|
|
|
|
|
|
_filteredTreeItems.Clear(); |
|
|
|
foreach (var item in filteredItems) |
|
|
|
{ |
|
|
|
_filteredTreeItems.Add(item); |
|
|
|
} |
|
|
|
|
|
|
|
// 如果当前选中的节点不在过滤结果中,清除选择
|
|
|
|
if (SelectedNode != null && !IsNodeInFilteredTree(SelectedNode)) |
|
|
|
{ |
|
|
|
SelectedNode = null; |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
catch (OperationCanceledException) |
|
|
|
{ |
|
|
|
// 过滤被取消,忽略
|
|
|
|
} |
|
|
|
catch (Exception ex) |
|
|
|
{ |
|
|
|
foreach (var item in _treeItems) |
|
|
|
_logger?.LogError(ex, "过滤文件树失败"); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private bool IsNodeInFilteredTree(FileTreeNode node) |
|
|
|
{ |
|
|
|
foreach (var item in _filteredTreeItems) |
|
|
|
{ |
|
|
|
if (IsNodeInSubtree(item, node)) |
|
|
|
{ |
|
|
|
_filteredTreeItems.Add(item); |
|
|
|
return true; |
|
|
|
} |
|
|
|
} |
|
|
|
return false; |
|
|
|
} |
|
|
|
|
|
|
|
return; |
|
|
|
private bool IsNodeInSubtree(FileTreeNode root, FileTreeNode target) |
|
|
|
{ |
|
|
|
if (root == target || root.FullPath == target.FullPath) |
|
|
|
{ |
|
|
|
return true; |
|
|
|
} |
|
|
|
|
|
|
|
foreach (var item in _treeItems) |
|
|
|
foreach (var child in root.Children) |
|
|
|
{ |
|
|
|
var filteredNode = CloneNodeWithFilter(item, keyword!); |
|
|
|
if (filteredNode != null) |
|
|
|
if (IsNodeInSubtree(child, target)) |
|
|
|
{ |
|
|
|
_filteredTreeItems.Add(filteredNode); |
|
|
|
return true; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
SelectedNode = null; |
|
|
|
return false; |
|
|
|
} |
|
|
|
|
|
|
|
private FileTreeNode? CloneNodeWithFilter(FileTreeNode source, string keyword) |
|
|
|
private FileTreeNode? CloneNodeWithFilter(FileTreeNode source, string keyword, CancellationToken cancellationToken) |
|
|
|
{ |
|
|
|
var comparison = StringComparison.CurrentCultureIgnoreCase; |
|
|
|
// 使用 OrdinalIgnoreCase 提高性能
|
|
|
|
var comparison = StringComparison.OrdinalIgnoreCase; |
|
|
|
var selfMatch = source.Name.Contains(keyword, comparison); |
|
|
|
var filteredChildren = new List<FileTreeNode>(); |
|
|
|
|
|
|
|
foreach (var child in source.Children) |
|
|
|
{ |
|
|
|
var filteredChild = CloneNodeWithFilter(child, keyword); |
|
|
|
if (cancellationToken.IsCancellationRequested) |
|
|
|
return null; |
|
|
|
|
|
|
|
var filteredChild = CloneNodeWithFilter(child, keyword, cancellationToken); |
|
|
|
if (filteredChild != null) |
|
|
|
{ |
|
|
|
filteredChildren.Add(filteredChild); |
|
|
|
@ -530,6 +620,8 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
|
|
|
|
public sealed class FileTreeNode |
|
|
|
{ |
|
|
|
private string? _cachedSizeDisplay; |
|
|
|
|
|
|
|
public FileTreeNode(string name, string fullPath, bool isDirectory, long size, DateTime lastModified) |
|
|
|
{ |
|
|
|
Name = name; |
|
|
|
@ -547,9 +639,25 @@ public class FileExplorerPageViewModel : RoutableViewModel |
|
|
|
public DateTime LastModified { get; } |
|
|
|
public ObservableCollection<FileTreeNode> Children { get; } |
|
|
|
|
|
|
|
public string SizeDisplay => IsDirectory |
|
|
|
? $"{Children.Count} 项" |
|
|
|
: FormatFileSize(Size); |
|
|
|
public string SizeDisplay |
|
|
|
{ |
|
|
|
get |
|
|
|
{ |
|
|
|
if (_cachedSizeDisplay == null) |
|
|
|
{ |
|
|
|
_cachedSizeDisplay = IsDirectory |
|
|
|
? $"{Children.Count} 项" |
|
|
|
: FormatFileSize(Size); |
|
|
|
} |
|
|
|
return _cachedSizeDisplay; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 当 Children 集合变化时,清除缓存
|
|
|
|
internal void InvalidateSizeDisplayCache() |
|
|
|
{ |
|
|
|
_cachedSizeDisplay = null; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private sealed record DirectoryTreeResult(FileTreeNode? Root, int FileCount); |
|
|
|
|