Add Explorer Workbench with hierarchical, off-UI Storage analysis.
Storage queries run in the background with cancellation and covering indexes so switching views no longer freezes the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
525
src/Explorer.App/MainWindow.xaml.cs
Normal file
525
src/Explorer.App/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,525 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Presentation;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private Point _dragStart;
|
||||
private bool _dragPending;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(MainViewModel.Theme))
|
||||
{
|
||||
ApplyTheme(vm.Theme);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private MainViewModel Vm => (MainViewModel)DataContext;
|
||||
|
||||
private void ApplyTheme(string theme)
|
||||
{
|
||||
var dicts = System.Windows.Application.Current.Resources.MergedDictionaries;
|
||||
dicts.Clear();
|
||||
var uri = theme == "Light"
|
||||
? new Uri("Themes/Light.xaml", UriKind.Relative)
|
||||
: new Uri("Themes/Dark.xaml", UriKind.Relative);
|
||||
dicts.Add(new ResourceDictionary { Source = uri });
|
||||
}
|
||||
|
||||
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ChangedButton != MouseButton.Left)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ClickCount == 2)
|
||||
{
|
||||
ToggleMaximized();
|
||||
return;
|
||||
}
|
||||
|
||||
DragMove();
|
||||
}
|
||||
|
||||
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
||||
|
||||
private void OnMaxRestore(object sender, RoutedEventArgs e) => ToggleMaximized();
|
||||
|
||||
private void OnCloseWindow(object sender, RoutedEventArgs e) => Close();
|
||||
|
||||
private void ToggleMaximized()
|
||||
{
|
||||
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
||||
MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
|
||||
}
|
||||
|
||||
private void OnPaneChromeMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: ExplorerPaneViewModel pane })
|
||||
{
|
||||
Vm.ActiveTab.Activate(pane);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
Vm.GoCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathHistorySelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ComboBox combo || e.AddedItems.Count == 0 || e.AddedItems[0] is not string path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (NavigationTreeViewModel.PathsEqual(path, Vm.ActivePane.CurrentPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (combo.IsDropDownOpen || combo.IsKeyboardFocusWithin)
|
||||
{
|
||||
Vm.PathText = path;
|
||||
Vm.GoCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearchKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
Vm.SearchCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnTreeSelected(object sender, RoutedPropertyChangedEventArgs<object> e)
|
||||
{
|
||||
if (Vm.Tree.IsRevealing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.NewValue is NavNodeViewModel node)
|
||||
{
|
||||
await Vm.TreeSelectAsync(node).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnTreeExpanded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is TreeViewItem { DataContext: NavNodeViewModel node })
|
||||
{
|
||||
await Vm.Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTabChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (Tabs.SelectedItem is ExplorerTabViewModel tab)
|
||||
{
|
||||
Vm.ActiveTab = tab;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPaneSplitLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Grid grid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplySplitFromGrid(grid);
|
||||
if (Equals(grid.Tag, "split-wired"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
grid.Tag = "split-wired";
|
||||
ExplorerTabViewModel? current = null;
|
||||
PropertyChangedEventHandler? handler = null;
|
||||
|
||||
void Wire(ExplorerTabViewModel? tab)
|
||||
{
|
||||
if (current is not null && handler is not null)
|
||||
{
|
||||
current.PropertyChanged -= handler;
|
||||
}
|
||||
|
||||
current = tab;
|
||||
if (tab is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
handler = (_, args) =>
|
||||
{
|
||||
if (args.PropertyName is nameof(ExplorerTabViewModel.IsSplit)
|
||||
or nameof(ExplorerTabViewModel.SplitRatio))
|
||||
{
|
||||
ApplySplitLayout(grid, tab);
|
||||
}
|
||||
};
|
||||
tab.PropertyChanged += handler;
|
||||
ApplySplitLayout(grid, tab);
|
||||
}
|
||||
|
||||
Wire(grid.DataContext as ExplorerTabViewModel);
|
||||
grid.DataContextChanged += (_, _) => Wire(grid.DataContext as ExplorerTabViewModel);
|
||||
grid.Unloaded += (_, _) => Wire(null);
|
||||
}
|
||||
|
||||
private void OnSplitDragCompleted(object sender, DragCompletedEventArgs e)
|
||||
{
|
||||
if (sender is not GridSplitter splitter || splitter.Parent is not Grid grid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (grid.DataContext is not ExplorerTabViewModel tab || !tab.IsSplit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var leftWidth = grid.ColumnDefinitions[0].ActualWidth;
|
||||
var rightWidth = grid.ColumnDefinitions[2].ActualWidth;
|
||||
var total = leftWidth + rightWidth;
|
||||
if (total < 8)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
tab.SetSplitRatio(leftWidth / total);
|
||||
ApplySplitLayout(grid, tab);
|
||||
}
|
||||
|
||||
private static void ApplySplitFromGrid(Grid grid)
|
||||
{
|
||||
if (grid.DataContext is ExplorerTabViewModel tab)
|
||||
{
|
||||
ApplySplitLayout(grid, tab);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySplitLayout(Grid grid, ExplorerTabViewModel tab)
|
||||
{
|
||||
var left = grid.ColumnDefinitions[0];
|
||||
var mid = grid.ColumnDefinitions[1];
|
||||
var right = grid.ColumnDefinitions[2];
|
||||
if (tab.IsSplit)
|
||||
{
|
||||
var ratio = Math.Clamp(tab.SplitRatio, ExplorerTabViewModel.MinSplitRatio, ExplorerTabViewModel.MaxSplitRatio);
|
||||
left.Width = new GridLength(ratio, GridUnitType.Star);
|
||||
left.MinWidth = 140;
|
||||
mid.Width = new GridLength(6);
|
||||
mid.MinWidth = 6;
|
||||
right.Width = new GridLength(1.0 - ratio, GridUnitType.Star);
|
||||
right.MinWidth = 140;
|
||||
}
|
||||
else
|
||||
{
|
||||
left.Width = new GridLength(1, GridUnitType.Star);
|
||||
left.MinWidth = 0;
|
||||
mid.Width = new GridLength(0);
|
||||
mid.MinWidth = 0;
|
||||
right.Width = new GridLength(0);
|
||||
right.MinWidth = 0;
|
||||
}
|
||||
|
||||
foreach (var splitter in grid.Children.OfType<GridSplitter>())
|
||||
{
|
||||
splitter.IsHitTestVisible = tab.IsSplit;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseTab(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { Tag: ExplorerTabViewModel tab })
|
||||
{
|
||||
Vm.CloseTab(tab);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnItemDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is ListView { SelectedItem: FolderItemViewModel item })
|
||||
{
|
||||
ActivatePaneFromList((ListView)sender);
|
||||
await Vm.ActivePane.OpenItemAsync(item).ConfigureAwait(true);
|
||||
Vm.PathText = Vm.ActivePane.CurrentPath;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ListView list)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ActivatePaneFromList(list);
|
||||
Vm.ActivePane.SelectedItems.Clear();
|
||||
foreach (FolderItemViewModel item in list.SelectedItems)
|
||||
{
|
||||
Vm.ActivePane.SelectedItems.Add(item);
|
||||
}
|
||||
|
||||
Vm.RefreshCloudActions();
|
||||
}
|
||||
|
||||
private void OnPaneFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is ListView list)
|
||||
{
|
||||
ActivatePaneFromList(list);
|
||||
}
|
||||
}
|
||||
|
||||
private void ActivatePaneFromList(ListView list)
|
||||
{
|
||||
var tab = Vm.ActiveTab;
|
||||
if (list.ItemsSource == tab.Right.Items)
|
||||
{
|
||||
tab.Activate(tab.Right);
|
||||
}
|
||||
else
|
||||
{
|
||||
tab.Activate(tab.Left);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_dragStart = e.GetPosition(null);
|
||||
_dragPending = true;
|
||||
}
|
||||
|
||||
private void OnListDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 ? DragDropEffects.Move : DragDropEffects.Copy;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private async void OnListDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop) || sender is not ListView list)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ActivatePaneFromList(list);
|
||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop)!;
|
||||
var move = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 || e.AllowedEffects == DragDropEffects.Move;
|
||||
await Vm.DropAsync(files, Vm.ActivePane.CurrentPath, move).ConfigureAwait(true);
|
||||
await Vm.RefreshAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseMove(MouseEventArgs e)
|
||||
{
|
||||
base.OnPreviewMouseMove(e);
|
||||
if (!_dragPending || e.LeftButton != MouseButtonState.Pressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pos = e.GetPosition(null);
|
||||
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
|
||||
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_dragPending = false;
|
||||
var paths = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToArray();
|
||||
if (paths.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new DataObject(DataFormats.FileDrop, paths);
|
||||
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move);
|
||||
}
|
||||
|
||||
private async void OnSearchDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is ListView { SelectedItem: FolderItemViewModel item })
|
||||
{
|
||||
Vm.Search.IsOpen = false;
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
await Vm.ActivePane.NavigateAsync(item.FullPath).ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Vm.ActivePane.NavigateAsync(PathRules.Parent(item.FullPath)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
Vm.PathText = Vm.ActivePane.CurrentPath;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStorageTreeDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject origin && IsInsideButton(origin))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Vm.Analysis.CanAct)
|
||||
{
|
||||
await Vm.OpenStorageHereAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInsideButton(DependencyObject origin)
|
||||
{
|
||||
for (var current = origin; current is not null;)
|
||||
{
|
||||
if (current is Button)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current is System.Windows.Media.Visual
|
||||
? System.Windows.Media.VisualTreeHelper.GetParent(current)
|
||||
: LogicalTreeHelper.GetParent(current);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async void OnStorageRankDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (Vm.Analysis.CanAct)
|
||||
{
|
||||
await Vm.OpenStorageHereAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseSearch(object sender, RoutedEventArgs e) => Vm.Search.IsOpen = false;
|
||||
private void OnCloseAnalysis(object sender, RoutedEventArgs e) => Vm.Analysis.Close();
|
||||
private void OnCloseDuplicates(object sender, RoutedEventArgs e) => Vm.Duplicates.IsOpen = false;
|
||||
|
||||
private void OnBuildIndex(object sender, RoutedEventArgs e) => Vm.BuildIndexCommand.Execute(null);
|
||||
|
||||
private void OnCtxOpen(object sender, RoutedEventArgs e) => Vm.OpenSelectedCommand.Execute(null);
|
||||
|
||||
private void OnCtxRename(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
|
||||
if (item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var name = PromptWindow.Ask(this, "Rename", "New name:", item.Name);
|
||||
if (!string.IsNullOrWhiteSpace(name) && name != item.Name)
|
||||
{
|
||||
Vm.RenameSelected(name);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAddNetwork(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var path = PromptWindow.Ask(this, "Add network location", "Network path (\\\\server\\share):", @"\\");
|
||||
if (!string.IsNullOrWhiteSpace(path) && path != @"\\")
|
||||
{
|
||||
Vm.PromptUnc = path;
|
||||
Vm.AddNetworkCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnAddOneDrive(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var picker = new Microsoft.Win32.OpenFolderDialog
|
||||
{
|
||||
Title = "Add OneDrive folder",
|
||||
Multiselect = false
|
||||
};
|
||||
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Vm.AddCloudFolderAsync(picker.FolderName).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
||||
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
|
||||
if (e.Key == Key.F5)
|
||||
{
|
||||
await Vm.RefreshAsync().ConfigureAwait(true);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.F2)
|
||||
{
|
||||
OnCtxRename(sender, e);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (ctrl && e.Key == Key.C)
|
||||
{
|
||||
Vm.Copy();
|
||||
}
|
||||
else if (ctrl && e.Key == Key.X)
|
||||
{
|
||||
Vm.Cut();
|
||||
}
|
||||
else if (ctrl && e.Key == Key.V)
|
||||
{
|
||||
await Vm.PasteAsync().ConfigureAwait(true);
|
||||
}
|
||||
else if (e.Key == Key.Delete)
|
||||
{
|
||||
await Vm.DeleteAsync().ConfigureAwait(true);
|
||||
}
|
||||
else if (ctrl && e.Key == Key.T)
|
||||
{
|
||||
await Vm.NewTabAsync().ConfigureAwait(true);
|
||||
}
|
||||
else if (ctrl && e.Key == Key.W)
|
||||
{
|
||||
Vm.CloseTab(Vm.ActiveTab);
|
||||
}
|
||||
else if (alt && e.Key == Key.Left)
|
||||
{
|
||||
await Vm.BackAsync().ConfigureAwait(true);
|
||||
}
|
||||
else if (alt && e.Key == Key.Right)
|
||||
{
|
||||
await Vm.ForwardAsync().ConfigureAwait(true);
|
||||
}
|
||||
else if (alt && e.Key == Key.Up)
|
||||
{
|
||||
await Vm.UpAsync().ConfigureAwait(true);
|
||||
}
|
||||
else if (e.Key == Key.Enter)
|
||||
{
|
||||
await Vm.OpenSelectedAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user