You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

560 lines
17 KiB

using AuroraDesk.Presentation.ViewModels.Base;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using AvaloniaEdit.Document;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
namespace AuroraDesk.Presentation.ViewModels.Pages;
/// <summary>
/// 目录文件浏览页面 ViewModel
/// </summary>
public class FileExplorerPageViewModel : RoutableViewModel
{
private readonly ILogger<FileExplorerPageViewModel>? _logger;
private readonly ObservableCollection<FileTreeNode> _treeItems = new();
private readonly ObservableCollection<FileTreeNode> _filteredTreeItems = new();
private readonly ObservableCollection<string> _availableHighlightLanguages = new();
private readonly Dictionary<string, string> _extensionLanguageMap;
private string _selectedDirectory = string.Empty;
private FileTreeNode? _selectedNode;
private string _statusMessage = "请选择上方目录以加载文件树";
private int _totalFileCount;
private bool _isLoading;
private readonly TextDocument _document;
private string _selectedHighlightLanguage = AutoDetectLanguageOption;
private string? _detectedHighlightLanguage;
private string _searchQuery = string.Empty;
public FileExplorerPageViewModel(
IScreen hostScreen,
ILogger<FileExplorerPageViewModel>? logger = null)
: base(hostScreen, "FileExplorer")
{
_logger = logger;
_document = new TextDocument("// 请选择左侧树中的文件以预览内容");
_extensionLanguageMap = CreateExtensionLanguageMap();
InitializeHighlightLanguages();
var canRefresh = this.WhenAnyValue(
x => x.SelectedDirectory,
dir => !string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir));
BrowseDirectoryCommand = ReactiveCommand.CreateFromTask(BrowseDirectoryAsync);
RefreshDirectoryCommand = ReactiveCommand.CreateFromTask(RefreshCurrentDirectoryAsync, canRefresh);
ApplyFilter();
}
public ObservableCollection<FileTreeNode> TreeItems => _treeItems;
public ObservableCollection<FileTreeNode> FilteredTreeItems => _filteredTreeItems;
public IReadOnlyList<string> AvailableHighlightLanguages => _availableHighlightLanguages;
public string SearchQuery
{
get => _searchQuery;
set
{
if (value == _searchQuery)
{
return;
}
this.RaiseAndSetIfChanged(ref _searchQuery, value);
ApplyFilter();
}
}
public string SelectedHighlightLanguage
{
get => _selectedHighlightLanguage;
set
{
if (value == _selectedHighlightLanguage)
{
return;
}
this.RaiseAndSetIfChanged(ref _selectedHighlightLanguage, value);
this.RaisePropertyChanged(nameof(ActiveHighlightLanguage));
}
}
public string ActiveHighlightLanguage =>
SelectedHighlightLanguage == AutoDetectLanguageOption
? _detectedHighlightLanguage ?? PlainTextLanguage
: SelectedHighlightLanguage;
public FileTreeNode? SelectedNode
{
get => _selectedNode;
set
{
this.RaiseAndSetIfChanged(ref _selectedNode, value);
this.RaisePropertyChanged(nameof(SelectedFileName));
this.RaisePropertyChanged(nameof(SelectedFileInfo));
if (value is null)
{
StatusMessage = "请选择左侧树中的文件";
_ = UpdateDocumentAsync("// 尚未选择文件");
}
else if (value.IsDirectory)
{
StatusMessage = $"已选中文件夹:{value.Name}";
_ = UpdateDocumentAsync("// 当前为文件夹,请展开并点击文件查看内容");
}
else
{
_ = LoadFileContentAsync(value);
}
}
}
public string SelectedDirectory
{
get => _selectedDirectory;
set => this.RaiseAndSetIfChanged(ref _selectedDirectory, value);
}
public string StatusMessage
{
get => _statusMessage;
set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
}
public int TotalFileCount
{
get => _totalFileCount;
set => this.RaiseAndSetIfChanged(ref _totalFileCount, value);
}
public bool IsLoading
{
get => _isLoading;
set => this.RaiseAndSetIfChanged(ref _isLoading, value);
}
public TextDocument Document => _document;
public string SelectedFileName => SelectedNode switch
{
null => "尚未选择文件",
{ IsDirectory: true } dir => $"{dir.Name}(文件夹)",
{ } file => file.Name
};
public string SelectedFileInfo => SelectedNode switch
{
null => "在左侧文件树中点击文件以查看内容",
{ IsDirectory: true } dir => $"{dir.Children.Count} 项 · 更新于 {dir.LastModified:yyyy-MM-dd HH:mm:ss}",
{ } file => $"{FormatFileSize(file.Size)} · 更新于 {file.LastModified:yyyy-MM-dd HH:mm:ss}"
};
public ReactiveCommand<Unit, Unit> BrowseDirectoryCommand { get; }
public ReactiveCommand<Unit, Unit> RefreshDirectoryCommand { get; }
private async Task BrowseDirectoryAsync()
{
try
{
var topLevel = GetMainWindow();
if (topLevel?.StorageProvider == null)
{
StatusMessage = "无法打开目录选择器";
_logger?.LogWarning("StorageProvider 不可用");
return;
}
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
AllowMultiple = false,
Title = "选择要浏览的文件夹"
});
if (folders.Count == 0)
{
return;
}
if (folders[0].TryGetLocalPath() is { } path)
{
await LoadDirectoryAsync(path);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "选择目录失败");
StatusMessage = $"选择目录失败:{ex.Message}";
}
}
private async Task RefreshCurrentDirectoryAsync()
{
if (string.IsNullOrWhiteSpace(SelectedDirectory))
{
return;
}
await LoadDirectoryAsync(SelectedDirectory);
}
private async Task LoadDirectoryAsync(string directoryPath)
{
if (!Directory.Exists(directoryPath))
{
StatusMessage = "目标目录不存在";
return;
}
try
{
IsLoading = true;
StatusMessage = "正在生成文件树...";
SelectedDirectory = directoryPath;
SelectedNode = null;
await UpdateDocumentAsync("// 请选择左侧树中的文件以预览内容");
var result = await Task.Run(() => BuildDirectoryTree(directoryPath));
await Dispatcher.UIThread.InvokeAsync(() =>
{
_treeItems.Clear();
if (result.Root != null)
{
_treeItems.Add(result.Root);
}
TotalFileCount = result.FileCount;
ApplyFilter();
});
StatusMessage = result.FileCount == 0
? "目录中没有文件"
: $"共 {result.FileCount} 个文件,展开树后点击文件即可预览";
}
catch (Exception ex)
{
_logger?.LogError(ex, "加载目录失败: {Directory}", directoryPath);
StatusMessage = $"加载目录失败:{ex.Message}";
}
finally
{
IsLoading = false;
}
}
private async Task LoadFileContentAsync(FileTreeNode node)
{
try
{
StatusMessage = $"正在读取 {node.Name}...";
var content = await Task.Run(() => File.ReadAllText(node.FullPath));
await Dispatcher.UIThread.InvokeAsync(() =>
{
Document.Text = content;
});
UpdateDetectedLanguage(node.FullPath);
StatusMessage = $"已加载 {node.Name}";
}
catch (Exception ex)
{
_logger?.LogError(ex, "读取文件失败: {File}", node.FullPath);
StatusMessage = $"读取文件失败:{ex.Message}";
await Dispatcher.UIThread.InvokeAsync(() =>
{
Document.Text = $"// 无法读取文件:{ex.Message}";
});
}
}
private async Task UpdateDocumentAsync(string placeholder)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
Document.Text = placeholder;
});
}
private DirectoryTreeResult BuildDirectoryTree(string directoryPath)
{
try
{
var rootInfo = new DirectoryInfo(directoryPath);
var (node, count) = CreateDirectoryNode(rootInfo);
return new DirectoryTreeResult(node, count);
}
catch (Exception ex)
{
_logger?.LogError(ex, "构建目录树失败: {Directory}", directoryPath);
return new DirectoryTreeResult(null, 0);
}
}
private (FileTreeNode node, int fileCount) CreateDirectoryNode(DirectoryInfo directoryInfo)
{
var node = new FileTreeNode(directoryInfo.Name, directoryInfo.FullName, true, 0, directoryInfo.LastWriteTime);
var children = new List<FileTreeNode>();
var fileCount = 0;
try
{
foreach (var subDir in directoryInfo.EnumerateDirectories())
{
try
{
var (childNode, childFiles) = CreateDirectoryNode(subDir);
children.Add(childNode);
fileCount += childFiles;
}
catch (UnauthorizedAccessException)
{
_logger?.LogWarning("无权限访问目录: {Directory}", subDir.FullName);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "处理目录失败: {Directory}", subDir.FullName);
}
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "枚举目录失败: {Directory}", directoryInfo.FullName);
}
try
{
foreach (var file in directoryInfo.EnumerateFiles())
{
try
{
var fileNode = new FileTreeNode(file.Name, file.FullName, false, file.Length, file.LastWriteTime);
children.Add(fileNode);
fileCount++;
}
catch (UnauthorizedAccessException)
{
_logger?.LogWarning("无权限访问文件: {File}", file.FullName);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "读取文件信息失败: {File}", file.FullName);
}
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "枚举文件失败: {Directory}", directoryInfo.FullName);
}
var orderedChildren = children
.OrderByDescending(c => c.IsDirectory)
.ThenBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
foreach (var child in orderedChildren)
{
node.Children.Add(child);
}
return (node, fileCount);
}
private void InitializeHighlightLanguages()
{
string[] defaults =
[
AutoDetectLanguageOption,
PlainTextLanguage,
"C#",
"JavaScript",
"TypeScript",
"HTML",
"CSS",
"JSON",
"XML",
"YAML",
"Markdown",
"Python",
"Java",
"SQL"
];
foreach (var lang in defaults)
{
_availableHighlightLanguages.Add(lang);
}
}
private Dictionary<string, string> CreateExtensionLanguageMap() =>
new(StringComparer.OrdinalIgnoreCase)
{
[".cs"] = "C#",
[".js"] = "JavaScript",
[".ts"] = "TypeScript",
[".html"] = "HTML",
[".htm"] = "HTML",
[".css"] = "CSS",
[".json"] = "JSON",
[".xml"] = "XML",
[".yml"] = "YAML",
[".yaml"] = "YAML",
[".md"] = "Markdown",
[".py"] = "Python",
[".java"] = "Java",
[".sql"] = "SQL"
};
private void UpdateDetectedLanguage(string filePath)
{
var language = DetectLanguageFromExtension(Path.GetExtension(filePath));
if (language != _detectedHighlightLanguage)
{
_detectedHighlightLanguage = language;
this.RaisePropertyChanged(nameof(ActiveHighlightLanguage));
}
}
private string DetectLanguageFromExtension(string? extension)
{
if (extension != null && _extensionLanguageMap.TryGetValue(extension, out var lang))
{
return lang;
}
return PlainTextLanguage;
}
private void ApplyFilter()
{
var keyword = SearchQuery?.Trim();
var useFilter = !string.IsNullOrWhiteSpace(keyword);
_filteredTreeItems.Clear();
if (!useFilter)
{
foreach (var item in _treeItems)
{
_filteredTreeItems.Add(item);
}
return;
}
foreach (var item in _treeItems)
{
var filteredNode = CloneNodeWithFilter(item, keyword!);
if (filteredNode != null)
{
_filteredTreeItems.Add(filteredNode);
}
}
SelectedNode = null;
}
private FileTreeNode? CloneNodeWithFilter(FileTreeNode source, string keyword)
{
var comparison = StringComparison.CurrentCultureIgnoreCase;
var selfMatch = source.Name.Contains(keyword, comparison);
var filteredChildren = new List<FileTreeNode>();
foreach (var child in source.Children)
{
var filteredChild = CloneNodeWithFilter(child, keyword);
if (filteredChild != null)
{
filteredChildren.Add(filteredChild);
}
}
if (!selfMatch && filteredChildren.Count == 0)
{
return null;
}
var clone = new FileTreeNode(source.Name, source.FullPath, source.IsDirectory, source.Size, source.LastModified);
foreach (var child in filteredChildren)
{
clone.Children.Add(child);
}
return clone;
}
private static Window? GetMainWindow()
{
var app = Avalonia.Application.Current;
if (app?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
return desktop.MainWindow;
}
return null;
}
private static string FormatFileSize(long sizeInBytes)
{
if (sizeInBytes < 1024)
return $"{sizeInBytes} B";
double size = sizeInBytes / 1024d;
if (size < 1024)
return $"{size:F1} KB";
size /= 1024;
if (size < 1024)
return $"{size:F1} MB";
size /= 1024;
return $"{size:F1} GB";
}
public sealed class FileTreeNode
{
public FileTreeNode(string name, string fullPath, bool isDirectory, long size, DateTime lastModified)
{
Name = name;
FullPath = fullPath;
IsDirectory = isDirectory;
Size = size;
LastModified = lastModified;
Children = new ObservableCollection<FileTreeNode>();
}
public string Name { get; }
public string FullPath { get; }
public bool IsDirectory { get; }
public long Size { get; }
public DateTime LastModified { get; }
public ObservableCollection<FileTreeNode> Children { get; }
public string SizeDisplay => IsDirectory
? $"{Children.Count} 项"
: FormatFileSize(Size);
}
private sealed record DirectoryTreeResult(FileTreeNode? Root, int FileCount);
private const string AutoDetectLanguageOption = "自动检测";
private const string PlainTextLanguage = "Plain Text";
}