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
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";
|
|
}
|
|
|
|
|