2026-04-04 03:28:51 +08:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Diagnostics;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Runtime.InteropServices;
|
|
|
|
|
using Avalonia;
|
|
|
|
|
using Avalonia.Controls;
|
|
|
|
|
using Avalonia.Input;
|
|
|
|
|
using Avalonia.Interactivity;
|
|
|
|
|
using Avalonia.Media;
|
2026-04-05 14:02:07 +08:00
|
|
|
using Avalonia.Media.Imaging;
|
|
|
|
|
using Avalonia.Platform;
|
2026-04-04 03:28:51 +08:00
|
|
|
using FluentIcons.Avalonia;
|
|
|
|
|
using LanMountainDesktop.ComponentSystem;
|
|
|
|
|
using LanMountainDesktop.Models;
|
|
|
|
|
using LanMountainDesktop.PluginSdk;
|
|
|
|
|
using LanMountainDesktop.Services;
|
|
|
|
|
|
|
|
|
|
namespace LanMountainDesktop.Views.Components;
|
|
|
|
|
|
|
|
|
|
public partial class FileManagerWidget : UserControl,
|
|
|
|
|
IDesktopComponentWidget,
|
|
|
|
|
IDesktopPageVisibilityAwareComponentWidget,
|
|
|
|
|
IComponentPlacementContextAware,
|
|
|
|
|
IDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly List<string> _navigationHistory = new();
|
|
|
|
|
private int _currentHistoryIndex = -1;
|
|
|
|
|
private string _currentPath = string.Empty;
|
|
|
|
|
private string _componentId = BuiltInComponentIds.DesktopFileManager;
|
|
|
|
|
private string _placementId = string.Empty;
|
|
|
|
|
private double _currentCellSize = 48;
|
|
|
|
|
private bool _isOnActivePage;
|
|
|
|
|
private bool _isEditMode;
|
|
|
|
|
private bool _isAttached;
|
|
|
|
|
private bool _isDisposed;
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
private const double TapMovementThreshold = 10;
|
|
|
|
|
private const long TapTimeThresholdMs = 500;
|
|
|
|
|
|
|
|
|
|
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
|
|
|
|
|
|
|
|
|
|
private record PointerGestureState(
|
|
|
|
|
Point StartPosition,
|
|
|
|
|
long StartTime,
|
|
|
|
|
FileSystemItem Item,
|
|
|
|
|
Border Border
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-04 03:28:51 +08:00
|
|
|
public FileManagerWidget()
|
|
|
|
|
{
|
|
|
|
|
InitializeComponent();
|
|
|
|
|
|
|
|
|
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
|
|
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
|
|
|
SizeChanged += OnSizeChanged;
|
|
|
|
|
|
|
|
|
|
ApplyCellSize(_currentCellSize);
|
|
|
|
|
NavigateToDrives();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void ApplyCellSize(double cellSize)
|
|
|
|
|
{
|
|
|
|
|
_currentCellSize = Math.Max(1, cellSize);
|
|
|
|
|
|
|
|
|
|
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
|
|
|
|
|
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
|
|
|
|
RootBorder.Padding = new Thickness(
|
|
|
|
|
Math.Clamp(_currentCellSize * 0.25, 10, 20),
|
|
|
|
|
Math.Clamp(_currentCellSize * 0.20, 8, 16));
|
|
|
|
|
|
|
|
|
|
ApplyLayoutMetrics();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
|
|
|
|
{
|
|
|
|
|
_isOnActivePage = isOnActivePage;
|
|
|
|
|
_isEditMode = isEditMode;
|
|
|
|
|
|
|
|
|
|
if (_isOnActivePage && _isAttached && !string.IsNullOrEmpty(_currentPath))
|
|
|
|
|
{
|
|
|
|
|
RefreshCurrentDirectory();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
|
|
|
|
{
|
|
|
|
|
_componentId = string.IsNullOrWhiteSpace(componentId)
|
|
|
|
|
? BuiltInComponentIds.DesktopFileManager
|
|
|
|
|
: componentId.Trim();
|
|
|
|
|
_placementId = placementId?.Trim() ?? string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
if (_isDisposed)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_isDisposed = true;
|
|
|
|
|
|
|
|
|
|
AttachedToVisualTree -= OnAttachedToVisualTree;
|
|
|
|
|
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
|
|
|
|
SizeChanged -= OnSizeChanged;
|
2026-04-05 14:02:07 +08:00
|
|
|
|
|
|
|
|
_gestureStates.Clear();
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_ = sender;
|
|
|
|
|
_ = e;
|
|
|
|
|
_isAttached = true;
|
|
|
|
|
|
|
|
|
|
if (_isOnActivePage)
|
|
|
|
|
{
|
|
|
|
|
RefreshCurrentDirectory();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_ = sender;
|
|
|
|
|
_ = e;
|
|
|
|
|
_isAttached = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_ = sender;
|
|
|
|
|
_ = e;
|
|
|
|
|
ApplyLayoutMetrics();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ApplyLayoutMetrics()
|
|
|
|
|
{
|
|
|
|
|
var scale = ResolveScale();
|
|
|
|
|
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4;
|
|
|
|
|
|
|
|
|
|
var buttonSize = Math.Clamp(32 * scale, 28, 40);
|
|
|
|
|
var iconSize = Math.Clamp(14 * scale, 12, 18);
|
|
|
|
|
var pathFontSize = Math.Clamp(13 * scale, 11, 16);
|
|
|
|
|
|
|
|
|
|
BackButton.Width = buttonSize;
|
|
|
|
|
BackButton.Height = buttonSize;
|
|
|
|
|
BackButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
|
|
|
|
|
|
|
|
|
HomeButton.Width = buttonSize;
|
|
|
|
|
HomeButton.Height = buttonSize;
|
|
|
|
|
HomeButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
|
|
|
|
|
|
|
|
|
RefreshButton.Width = buttonSize;
|
|
|
|
|
RefreshButton.Height = buttonSize;
|
|
|
|
|
RefreshButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
|
|
|
|
|
|
|
|
|
PathTextBlock.FontSize = pathFontSize;
|
|
|
|
|
|
|
|
|
|
if (BackButton.Content is SymbolIcon backIcon)
|
|
|
|
|
{
|
|
|
|
|
backIcon.FontSize = iconSize;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (HomeButton.Content is SymbolIcon homeIcon)
|
|
|
|
|
{
|
|
|
|
|
homeIcon.FontSize = iconSize;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (RefreshButton.Content is SymbolIcon refreshIcon)
|
|
|
|
|
{
|
|
|
|
|
refreshIcon.FontSize = iconSize;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private double ResolveScale()
|
|
|
|
|
{
|
|
|
|
|
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
|
|
|
|
|
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 280d, 0.72, 2.4) : 1;
|
|
|
|
|
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 280d, 0.72, 2.4) : 1;
|
|
|
|
|
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnBackButtonClick(object? sender, RoutedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_ = sender;
|
|
|
|
|
_ = e;
|
|
|
|
|
|
|
|
|
|
if (_currentHistoryIndex > 0)
|
|
|
|
|
{
|
|
|
|
|
_currentHistoryIndex--;
|
|
|
|
|
var path = _navigationHistory[_currentHistoryIndex];
|
|
|
|
|
LoadDirectory(path, addToHistory: false);
|
|
|
|
|
}
|
|
|
|
|
else if (_currentHistoryIndex == 0 && _navigationHistory.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
NavigateToDrives();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnHomeButtonClick(object? sender, RoutedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_ = sender;
|
|
|
|
|
_ = e;
|
|
|
|
|
NavigateToDrives();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
_ = sender;
|
|
|
|
|
_ = e;
|
|
|
|
|
RefreshCurrentDirectory();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (sender is not Border border || border.DataContext is not FileSystemItem item)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
var pointer = e.GetCurrentPoint(border);
|
|
|
|
|
var pointerId = e.Pointer.Id;
|
|
|
|
|
var position = pointer.Position;
|
|
|
|
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
|
|
|
|
|
|
|
_gestureStates[pointerId] = new PointerGestureState(position, timestamp, item, border);
|
|
|
|
|
|
|
|
|
|
e.Pointer.Capture(border);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnItemPointerMoved(object? sender, PointerEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (sender is not Border border)
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
return;
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
2026-04-05 14:02:07 +08:00
|
|
|
|
|
|
|
|
var pointerId = e.Pointer.Id;
|
|
|
|
|
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var currentPoint = e.GetCurrentPoint(border);
|
|
|
|
|
var distance = Math.Sqrt(
|
|
|
|
|
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
|
|
|
|
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (distance > TapMovementThreshold)
|
|
|
|
|
{
|
|
|
|
|
_gestureStates.Remove(pointerId);
|
|
|
|
|
e.Pointer.Capture(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnItemPointerReleased(object? sender, PointerReleasedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (sender is not Border border)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var pointerId = e.Pointer.Id;
|
|
|
|
|
if (!_gestureStates.Remove(pointerId, out var state))
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.Pointer.Capture(null);
|
|
|
|
|
|
|
|
|
|
var currentPoint = e.GetCurrentPoint(border);
|
|
|
|
|
var distance = Math.Sqrt(
|
|
|
|
|
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
|
|
|
|
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
|
|
|
|
|
|
|
|
|
|
if (distance <= TapMovementThreshold && elapsed <= TapTimeThresholdMs)
|
|
|
|
|
{
|
|
|
|
|
if (state.Item.IsDirectory)
|
|
|
|
|
{
|
|
|
|
|
LoadDirectory(state.Item.FullPath, addToHistory: true);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
OpenFile(state.Item.FullPath);
|
|
|
|
|
}
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void NavigateToDrives()
|
|
|
|
|
{
|
|
|
|
|
_navigationHistory.Clear();
|
|
|
|
|
_currentHistoryIndex = -1;
|
|
|
|
|
_currentPath = string.Empty;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var drives = new List<FileSystemItem>();
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
foreach (var drive in DriveInfo.GetDrives())
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (!drive.IsReady)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var item = FileSystemItem.FromDriveInfo(drive);
|
|
|
|
|
drives.Add(item);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
AppLogger.Warn("FileManagerWidget", $"Failed to access drive: {drive?.Name}", ex);
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
2026-04-05 14:02:07 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
|
|
|
{
|
|
|
|
|
drives.Add(new FileSystemItem
|
|
|
|
|
{
|
|
|
|
|
Name = "根目录",
|
|
|
|
|
FullPath = "/",
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
2026-04-04 03:28:51 +08:00
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
|
|
|
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
|
|
|
|
{
|
|
|
|
|
drives.Add(new FileSystemItem
|
|
|
|
|
{
|
|
|
|
|
Name = "主目录",
|
|
|
|
|
FullPath = homePath,
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var linuxMountPoints = new[] { "/mnt", "/media", "/run/media" };
|
|
|
|
|
foreach (var mount in linuxMountPoints)
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(mount))
|
|
|
|
|
{
|
|
|
|
|
drives.Add(new FileSystemItem
|
|
|
|
|
{
|
|
|
|
|
Name = mount,
|
|
|
|
|
FullPath = mount,
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
2026-04-05 14:02:07 +08:00
|
|
|
}
|
|
|
|
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
|
|
|
{
|
|
|
|
|
drives.Add(new FileSystemItem
|
|
|
|
|
{
|
|
|
|
|
Name = "根目录",
|
|
|
|
|
FullPath = "/",
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
drives.Add(new FileSystemItem
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
Name = "用户",
|
|
|
|
|
FullPath = "/Users",
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
drives.Add(new FileSystemItem
|
|
|
|
|
{
|
|
|
|
|
Name = "应用程序",
|
|
|
|
|
FullPath = "/Applications",
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
|
|
|
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
|
|
|
|
{
|
|
|
|
|
drives.Add(new FileSystemItem
|
|
|
|
|
{
|
|
|
|
|
Name = "个人",
|
|
|
|
|
FullPath = homePath,
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Directory.Exists("/Volumes"))
|
|
|
|
|
{
|
|
|
|
|
foreach (var volume in Directory.GetDirectories("/Volumes"))
|
|
|
|
|
{
|
|
|
|
|
drives.Add(new FileSystemItem
|
|
|
|
|
{
|
|
|
|
|
Name = Path.GetFileName(volume),
|
|
|
|
|
FullPath = volume,
|
|
|
|
|
ItemType = FileSystemItemType.Directory
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RenderFileItems(drives);
|
2026-04-05 14:02:07 +08:00
|
|
|
PathTextBlock.Text = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
2026-04-04 03:28:51 +08:00
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
UpdateEmptyState(drives.Count == 0, "没有可用的位置");
|
2026-04-04 03:28:51 +08:00
|
|
|
ErrorStatePanel.IsVisible = false;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
AppLogger.Warn("FileManagerWidget", "Failed to load drives.", ex);
|
2026-04-05 14:02:07 +08:00
|
|
|
ShowError("无法加载位置列表");
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void LoadDirectory(string path, bool addToHistory)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
|
|
|
{
|
|
|
|
|
NavigateToDrives();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var directoryInfo = new DirectoryInfo(path);
|
|
|
|
|
|
|
|
|
|
if (!directoryInfo.Exists)
|
|
|
|
|
{
|
|
|
|
|
ShowError("文件夹不存在");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var items = new List<FileSystemItem>();
|
|
|
|
|
|
|
|
|
|
// 添加子文件夹
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var directories = directoryInfo.GetDirectories()
|
|
|
|
|
.Where(d => (d.Attributes & FileAttributes.Hidden) == 0)
|
|
|
|
|
.OrderBy(d => d.Name)
|
|
|
|
|
.Select(FileSystemItem.FromDirectoryInfo);
|
|
|
|
|
items.AddRange(directories);
|
|
|
|
|
}
|
|
|
|
|
catch (UnauthorizedAccessException)
|
|
|
|
|
{
|
|
|
|
|
// 忽略无权限访问的文件夹
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 添加文件
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var files = directoryInfo.GetFiles()
|
|
|
|
|
.Where(f => (f.Attributes & FileAttributes.Hidden) == 0)
|
|
|
|
|
.OrderBy(f => f.Name)
|
|
|
|
|
.Select(FileSystemItem.FromFileInfo);
|
|
|
|
|
items.AddRange(files);
|
|
|
|
|
}
|
|
|
|
|
catch (UnauthorizedAccessException)
|
|
|
|
|
{
|
|
|
|
|
// 忽略无权限访问的文件
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RenderFileItems(items);
|
|
|
|
|
_currentPath = path;
|
|
|
|
|
PathTextBlock.Text = FormatPathForDisplay(path);
|
|
|
|
|
|
|
|
|
|
if (addToHistory)
|
|
|
|
|
{
|
|
|
|
|
// 移除当前位置之后的历史记录
|
|
|
|
|
if (_currentHistoryIndex < _navigationHistory.Count - 1)
|
|
|
|
|
{
|
|
|
|
|
_navigationHistory.RemoveRange(_currentHistoryIndex + 1, _navigationHistory.Count - _currentHistoryIndex - 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_navigationHistory.Add(path);
|
|
|
|
|
_currentHistoryIndex = _navigationHistory.Count - 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
UpdateEmptyState(items.Count == 0, "文件夹为空");
|
|
|
|
|
ErrorStatePanel.IsVisible = false;
|
|
|
|
|
}
|
|
|
|
|
catch (UnauthorizedAccessException)
|
|
|
|
|
{
|
|
|
|
|
ShowError("没有权限访问此文件夹");
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
AppLogger.Warn("FileManagerWidget", $"Failed to load directory: {path}", ex);
|
|
|
|
|
ShowError("无法加载文件夹内容");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void RenderFileItems(List<FileSystemItem> items)
|
|
|
|
|
{
|
|
|
|
|
FileItemsControl.ItemsSource = null;
|
|
|
|
|
FileItemsControl.Items.Clear();
|
|
|
|
|
|
|
|
|
|
foreach (var item in items)
|
|
|
|
|
{
|
|
|
|
|
var itemControl = CreateFileItemControl(item);
|
|
|
|
|
FileItemsControl.Items.Add(itemControl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Control CreateFileItemControl(FileSystemItem item)
|
|
|
|
|
{
|
|
|
|
|
var scale = ResolveScale();
|
|
|
|
|
var itemWidth = Math.Clamp(72 * scale, 64, 96);
|
|
|
|
|
var itemHeight = Math.Clamp(80 * scale, 72, 108);
|
|
|
|
|
var iconSize = Math.Clamp(32 * scale, 24, 40);
|
|
|
|
|
var fontSize = Math.Clamp(11 * scale, 10, 14);
|
|
|
|
|
|
|
|
|
|
var textBrush = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White);
|
|
|
|
|
|
|
|
|
|
var border = new Border
|
|
|
|
|
{
|
|
|
|
|
Width = itemWidth,
|
|
|
|
|
Height = itemHeight,
|
|
|
|
|
Margin = new Thickness(4),
|
|
|
|
|
CornerRadius = new CornerRadius(8),
|
|
|
|
|
Background = new SolidColorBrush(Colors.Transparent),
|
|
|
|
|
Cursor = new Cursor(StandardCursorType.Hand),
|
|
|
|
|
DataContext = item
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var grid = new Grid
|
|
|
|
|
{
|
|
|
|
|
RowDefinitions = new RowDefinitions("*,Auto"),
|
|
|
|
|
Margin = new Thickness(4)
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
var iconImage = CreateSystemIconImage(item, iconSize);
|
2026-04-04 03:28:51 +08:00
|
|
|
|
|
|
|
|
var textBlock = new TextBlock
|
|
|
|
|
{
|
|
|
|
|
Text = item.Name,
|
|
|
|
|
FontSize = fontSize,
|
|
|
|
|
TextAlignment = TextAlignment.Center,
|
|
|
|
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
|
|
|
|
MaxLines = 2,
|
|
|
|
|
TextWrapping = TextWrapping.Wrap,
|
|
|
|
|
Foreground = textBrush
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
if (iconImage is not null)
|
|
|
|
|
{
|
|
|
|
|
grid.Children.Add(iconImage);
|
|
|
|
|
Grid.SetRow(iconImage, 0);
|
|
|
|
|
}
|
2026-04-04 03:28:51 +08:00
|
|
|
|
|
|
|
|
grid.Children.Add(textBlock);
|
|
|
|
|
Grid.SetRow(textBlock, 1);
|
|
|
|
|
|
|
|
|
|
border.Child = grid;
|
|
|
|
|
|
|
|
|
|
ToolTip.SetTip(border, item.Name);
|
|
|
|
|
|
|
|
|
|
border.PointerPressed += OnItemPointerPressed;
|
2026-04-05 14:02:07 +08:00
|
|
|
border.PointerMoved += OnItemPointerMoved;
|
|
|
|
|
border.PointerReleased += OnItemPointerReleased;
|
2026-04-04 03:28:51 +08:00
|
|
|
|
|
|
|
|
return border;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
private Control? CreateSystemIconImage(FileSystemItem item, double iconSize)
|
|
|
|
|
{
|
|
|
|
|
byte[]? pngBytes = null;
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
|
|
|
{
|
|
|
|
|
pngBytes = item.ItemType switch
|
|
|
|
|
{
|
|
|
|
|
FileSystemItemType.Drive => GetDriveIconBytes(item.FullPath),
|
|
|
|
|
FileSystemItemType.Directory => WindowsIconService.TryGetSystemFolderIconPngBytes(),
|
|
|
|
|
_ => WindowsIconService.TryGetIconPngBytes(item.FullPath)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
else if (OperatingSystem.IsLinux())
|
|
|
|
|
{
|
|
|
|
|
pngBytes = item.ItemType switch
|
|
|
|
|
{
|
|
|
|
|
FileSystemItemType.Drive => LinuxIconService.TryGetDriveIconPngBytes(),
|
|
|
|
|
FileSystemItemType.Directory => LinuxIconService.TryGetSystemFolderIconPngBytes(),
|
|
|
|
|
_ => LinuxIconService.TryGetIconPngBytes(item.FullPath)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
else if (OperatingSystem.IsMacOS())
|
|
|
|
|
{
|
|
|
|
|
pngBytes = item.ItemType switch
|
|
|
|
|
{
|
|
|
|
|
FileSystemItemType.Drive => MacIconService.TryGetDriveIconPngBytes(),
|
|
|
|
|
FileSystemItemType.Directory => MacIconService.TryGetSystemFolderIconPngBytes(),
|
|
|
|
|
_ => MacIconService.TryGetIconPngBytes(item.FullPath)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
pngBytes = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pngBytes is not null)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var stream = new MemoryStream(pngBytes);
|
|
|
|
|
var bitmap = new Bitmap(stream);
|
|
|
|
|
return new Image
|
|
|
|
|
{
|
|
|
|
|
Source = bitmap,
|
|
|
|
|
Width = iconSize,
|
|
|
|
|
Height = iconSize,
|
|
|
|
|
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
|
|
|
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
|
|
|
|
Stretch = Stretch.Uniform
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return CreateFallbackIconImage(item, iconSize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static byte[]? GetDriveIconBytes(string drivePath)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(drivePath))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(drivePath))
|
|
|
|
|
{
|
|
|
|
|
return WindowsIconService.TryGetIconPngBytes(drivePath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
else if (OperatingSystem.IsLinux())
|
|
|
|
|
{
|
|
|
|
|
return LinuxIconService.TryGetDriveIconPngBytes();
|
|
|
|
|
}
|
|
|
|
|
else if (OperatingSystem.IsMacOS())
|
|
|
|
|
{
|
|
|
|
|
return MacIconService.TryGetDriveIconPngBytes();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
|
|
|
{
|
|
|
|
|
return WindowsIconService.TryGetSystemFolderIconPngBytes();
|
|
|
|
|
}
|
|
|
|
|
else if (OperatingSystem.IsLinux())
|
|
|
|
|
{
|
|
|
|
|
return LinuxIconService.TryGetSystemFolderIconPngBytes();
|
|
|
|
|
}
|
|
|
|
|
else if (OperatingSystem.IsMacOS())
|
|
|
|
|
{
|
|
|
|
|
return MacIconService.TryGetSystemFolderIconPngBytes();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Control CreateFallbackIconImage(FileSystemItem item, double iconSize)
|
|
|
|
|
{
|
|
|
|
|
var symbol = item.ItemType switch
|
|
|
|
|
{
|
|
|
|
|
FileSystemItemType.Drive => FluentIcons.Common.Symbol.HardDrive,
|
|
|
|
|
FileSystemItemType.Directory => FluentIcons.Common.Symbol.Folder,
|
|
|
|
|
_ => FluentIcons.Common.Symbol.Document
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var iconBrush = item.ItemType == FileSystemItemType.File
|
|
|
|
|
? this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray)
|
|
|
|
|
: this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue);
|
|
|
|
|
|
|
|
|
|
return new SymbolIcon
|
|
|
|
|
{
|
|
|
|
|
Symbol = symbol,
|
|
|
|
|
FontSize = iconSize,
|
|
|
|
|
Foreground = iconBrush,
|
|
|
|
|
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
|
|
|
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 03:28:51 +08:00
|
|
|
private void RefreshCurrentDirectory()
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrEmpty(_currentPath))
|
|
|
|
|
{
|
|
|
|
|
NavigateToDrives();
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
LoadDirectory(_currentPath, addToHistory: false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void UpdateEmptyState(bool isEmpty, string message)
|
|
|
|
|
{
|
|
|
|
|
EmptyStatePanel.IsVisible = isEmpty;
|
|
|
|
|
EmptyStateTextBlock.Text = message;
|
|
|
|
|
FileItemsControl.IsVisible = !isEmpty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ShowError(string message)
|
|
|
|
|
{
|
|
|
|
|
ErrorStatePanel.IsVisible = true;
|
|
|
|
|
ErrorStateTextBlock.Text = message;
|
|
|
|
|
FileItemsControl.IsVisible = false;
|
|
|
|
|
EmptyStatePanel.IsVisible = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void OpenFile(string filePath)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
|
|
|
{
|
|
|
|
|
Process.Start(new ProcessStartInfo(filePath)
|
|
|
|
|
{
|
|
|
|
|
UseShellExecute = true
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
|
|
|
{
|
|
|
|
|
Process.Start("xdg-open", filePath);
|
|
|
|
|
}
|
|
|
|
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
|
|
|
{
|
|
|
|
|
Process.Start("open", filePath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
AppLogger.Warn("FileManagerWidget", $"Failed to open file: {filePath}", ex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string FormatPathForDisplay(string path)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
|
|
|
|
|
|
|
|
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
if (path.Length <= 3 && path.EndsWith(":\\", StringComparison.OrdinalIgnoreCase))
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var driveInfo = new DriveInfo(path.Substring(0, 1));
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(driveInfo.VolumeLabel))
|
|
|
|
|
{
|
|
|
|
|
return $"{driveInfo.VolumeLabel} ({path.Substring(0, 2)})";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
|
|
|
|
}
|
2026-04-05 14:02:07 +08:00
|
|
|
return path;
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
2026-04-05 14:02:07 +08:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
if (path == "/")
|
2026-04-04 03:28:51 +08:00
|
|
|
{
|
2026-04-05 14:02:07 +08:00
|
|
|
return "根目录";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
|
|
|
|
{
|
|
|
|
|
return "主目录";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
|
|
|
{
|
|
|
|
|
if (path == "/Applications")
|
|
|
|
|
{
|
|
|
|
|
return "应用程序";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path == "/Users")
|
|
|
|
|
{
|
|
|
|
|
return "用户";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (path.StartsWith("/Volumes/"))
|
|
|
|
|
{
|
|
|
|
|
return Path.GetFileName(path);
|
|
|
|
|
}
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var parts = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
if (parts.Length <= 3)
|
|
|
|
|
{
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 14:02:07 +08:00
|
|
|
return $"{parts[0]}{separator}...{separator}{parts[^2]}{separator}{parts[^1]}";
|
2026-04-04 03:28:51 +08:00
|
|
|
}
|
|
|
|
|
}
|