using System.ComponentModel; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using Explorer.Domain; using Explorer.Presentation; using Explorer.Presentation.ViewModels; namespace Explorer.App; public partial class MainWindow : Window { private Point _dragStart; private bool _dragPending; private MouseButton _dragButton; private FolderItemViewModel? _dragItem; private bool _suppressItemContextMenu; private bool _incomingRightDrag; private bool _sourceRightDrag; 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 e) { if (Vm.Tree.IsRevealing) { return; } if (e.NewValue is NavNodeViewModel node) { await Vm.TreeSelectAsync(node).ConfigureAwait(true); } } private void OnTreeContextOpening(object sender, ContextMenuEventArgs e) { if (_suppressItemContextMenu) { e.Handled = true; _suppressItemContextMenu = false; return; } var node = FindTreeNode(e.OriginalSource as DependencyObject); if (node is null || !node.CanRemove) { e.Handled = true; return; } node.IsSelected = true; RemoveLocationMenu.Tag = node.Path; } private async void OnRemoveLocation(object sender, RoutedEventArgs e) { var path = (sender as FrameworkElement)?.Tag as string ?? RemoveLocationMenu.Tag as string ?? Vm.ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? (Vm.Tree.Roots.SelectMany(FlattenTree).FirstOrDefault(n => n.IsSelected && n.CanRemove)?.Path); if (string.IsNullOrWhiteSpace(path)) { return; } var label = Vm.Tree.Roots.SelectMany(FlattenTree).FirstOrDefault(n => NavigationTreeViewModel.PathsEqual(n.Path, path))?.Label ?? path; var confirm = MessageBox.Show( this, $"Remove “{label}” from Explorer?\n\nThe link and its index data will be deleted. Files on disk or the server are not touched.\n\nIf Windows still has this location, it will appear again.", "Remove from Explorer", MessageBoxButton.YesNo, MessageBoxImage.Question); if (confirm != MessageBoxResult.Yes) { return; } await Vm.ForgetSourceAsync(path).ConfigureAwait(true); } private static IEnumerable FlattenTree(NavNodeViewModel node) { yield return node; foreach (var child in node.Children.Where(c => !c.IsPlaceholder).SelectMany(FlattenTree)) { yield return child; } } private static NavNodeViewModel? FindTreeNode(DependencyObject? origin) { while (origin is not null) { if (origin is TreeViewItem { DataContext: NavNodeViewModel node }) { return node; } origin = origin is Visual ? VisualTreeHelper.GetParent(origin) : LogicalTreeHelper.GetParent(origin); } return null; } 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()) { 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) { _suppressItemContextMenu = false; _dragStart = e.GetPosition(null); _dragPending = true; _dragButton = e.ChangedButton; _dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender)); } private void OnListContextMenuOpening(object sender, ContextMenuEventArgs e) { if (_suppressItemContextMenu) { e.Handled = true; _suppressItemContextMenu = false; } } private void OnListDragOver(object sender, DragEventArgs e) { if (!TryGetDropFiles(e, out var files) || sender is not ListView list) { e.Effects = DragDropEffects.None; e.Handled = true; return; } ActivatePaneFromList(list); _incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0; var dest = ResolveDropDirectory(list, e); if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest)) { e.Effects = DragDropEffects.None; e.Handled = true; return; } if (_sourceRightDrag || _incomingRightDrag) { e.Effects = DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link; } else { e.Effects = ToEffects(ResolveDropAction(e, files, dest)); } e.Handled = true; } private async void OnListDrop(object sender, DragEventArgs e) { if (!TryGetDropFiles(e, out var files) || sender is not ListView list) { return; } ActivatePaneFromList(list); var dest = ResolveDropDirectory(list, e); if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest)) { return; } var right = IsRightDrop(e); _incomingRightDrag = false; _sourceRightDrag = false; if (right) { ShowDropMenu(list, e.GetPosition(list), files, dest); return; } await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true); } protected override void OnPreviewMouseMove(MouseEventArgs e) { base.OnPreviewMouseMove(e); var held = _dragButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed || _dragButton == MouseButton.Right && e.RightButton == MouseButtonState.Pressed; if (!_dragPending || !held) { 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 selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList(); IReadOnlyList paths; if (_dragItem is not null && (selected.Count == 0 || selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase)))) { paths = [_dragItem.FullPath]; } else { paths = selected; } if (paths.Count == 0) { return; } _sourceRightDrag = _dragButton == MouseButton.Right; _suppressItemContextMenu = _sourceRightDrag; var data = new DataObject(DataFormats.FileDrop, paths.ToArray()); DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link); _sourceRightDrag = false; _incomingRightDrag = false; } protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e) { if (e.EscapePressed) { e.Action = DragAction.Cancel; e.Handled = true; return; } var buttonHeld = (e.KeyStates & DragDropKeyStates.LeftMouseButton) != 0 || (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0; e.Action = buttonHeld ? DragAction.Continue : DragAction.Drop; e.Handled = true; } protected override void OnPreviewMouseUp(MouseButtonEventArgs e) { base.OnPreviewMouseUp(e); if (e.ChangedButton == _dragButton) { _dragPending = false; } } private static bool TryGetDropFiles(DragEventArgs e, out string[] files) { if (e.Data.GetDataPresent(DataFormats.FileDrop) && e.Data.GetData(DataFormats.FileDrop) is string[] dropped && dropped.Length > 0) { files = dropped; return true; } files = []; return false; } private string? ResolveDropDirectory(ListView list, DragEventArgs e) { var item = HitTestFolderItem(list, e.GetPosition(list)); if (item is { IsDirectory: true }) { return item.FullPath; } var tab = Vm.ActiveTab; var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath; return LocationRoots.IsVirtual(path) ? null : path; } private static FolderItemViewModel? HitTestFolderItem(DependencyObject? origin, Point point) { if (origin is not Visual visual) { return null; } var hit = VisualTreeHelper.HitTest(visual, point)?.VisualHit as DependencyObject; while (hit is not null) { if (hit is ListViewItem { DataContext: FolderItemViewModel item }) { return item; } hit = VisualTreeHelper.GetParent(hit); } return null; } private static DragDropEffects ToEffects(DropAction action) => action switch { DropAction.Copy => DragDropEffects.Copy, DropAction.Move => DragDropEffects.Move, DropAction.Link => DragDropEffects.Link, _ => DragDropEffects.None }; private DropAction ResolveDropAction(DragEventArgs e, IReadOnlyList files, string dest) { var sameVolume = files.All(f => PathRules.IsSameVolume(f, dest)); return DragDropPolicy.Resolve( (e.KeyStates & DragDropKeyStates.ControlKey) != 0, (e.KeyStates & DragDropKeyStates.ShiftKey) != 0, (e.KeyStates & DragDropKeyStates.AltKey) != 0, sameVolume); } private bool IsRightDrop(DragEventArgs e) => _sourceRightDrag || _incomingRightDrag || (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0; private void ShowDropMenu(FrameworkElement target, Point position, IReadOnlyList files, string dest) { var menu = new ContextMenu { PlacementTarget = target, Placement = PlacementMode.RelativePoint, HorizontalOffset = position.X, VerticalOffset = position.Y }; void Add(string header, DropAction action) { var item = new MenuItem { Header = header }; item.Click += async (_, _) => await ApplyDropAsync(files, dest, action).ConfigureAwait(true); menu.Items.Add(item); } Add("Copy here", DropAction.Copy); Add("Move here", DropAction.Move); Add("Create shortcuts here", DropAction.Link); menu.Items.Add(new Separator()); menu.Items.Add(new MenuItem { Header = "Cancel" }); menu.IsOpen = true; } private async Task ApplyDropAsync(IReadOnlyList files, string dest, DropAction action) { if (action == DropAction.None) { return; } await Vm.DropAsync(files, dest, action).ConfigureAwait(true); await Vm.RefreshAsync().ConfigureAwait(true); } private void OnTreeDragOver(object sender, DragEventArgs e) { if (!TryGetDropFiles(e, out var files)) { e.Effects = DragDropEffects.None; e.Handled = true; return; } _incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0; var dest = HitTestTreePath(e); if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest)) { e.Effects = DragDropEffects.None; e.Handled = true; return; } e.Effects = _incomingRightDrag || _sourceRightDrag ? DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link : ToEffects(ResolveDropAction(e, files, dest)); e.Handled = true; } private async void OnTreeDrop(object sender, DragEventArgs e) { if (!TryGetDropFiles(e, out var files)) { return; } var dest = HitTestTreePath(e); if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest)) { return; } var right = IsRightDrop(e); _incomingRightDrag = false; _sourceRightDrag = false; if (right) { ShowDropMenu(NavTree, e.GetPosition(NavTree), files, dest); return; } await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true); } private string? HitTestTreePath(DragEventArgs e) { var origin = NavTree.InputHitTest(e.GetPosition(NavTree)) as DependencyObject ?? e.OriginalSource as DependencyObject; var node = FindTreeNode(origin); if (node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path)) { return null; } return node.Path; } 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 async void OnCtxDelete(object sender, RoutedEventArgs e) => await DeleteSelectedAsync().ConfigureAwait(true); private async Task DeleteSelectedAsync() { if (Vm.ActivePane.SelectedItems.Count == 0) { return; } var permanent = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift); if (permanent) { var count = Vm.ActivePane.SelectedItems.Count; var name = Vm.ActivePane.SelectedItems[0].Name; var text = count == 1 ? $"Are you sure you want to permanently delete '{name}'?" : $"Are you sure you want to permanently delete these {count} items?"; if (MessageBox.Show(this, text, "Delete Permanently", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes) { return; } } await Vm.DeleteSelectedAsync(permanent).ConfigureAwait(true); } 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 async void OnOpenSettings(object sender, RoutedEventArgs e) { var dlg = new SettingsWindow(Vm) { Owner = this }; dlg.ShowDialog(); await Vm.RefreshForgetActionAsync().ConfigureAwait(true); } 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) => await AddCloudFolderAsync("Add OneDrive folder", MainViewModel.OneDriveProviderId).ConfigureAwait(true); private async void OnAddGoogleDrive(object sender, RoutedEventArgs e) => await AddCloudFolderAsync("Add Google Drive folder", MainViewModel.GoogleDriveProviderId).ConfigureAwait(true); private async void OnAddNextcloud(object sender, RoutedEventArgs e) => await AddCloudFolderAsync("Add Nextcloud folder", MainViewModel.NextcloudProviderId).ConfigureAwait(true); private async Task AddCloudFolderAsync(string title, string providerId) { var picker = new Microsoft.Win32.OpenFolderDialog { Title = title, Multiselect = false }; if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName)) { return; } await Vm.AddCloudFolderAsync(picker.FolderName, providerId).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 DeleteSelectedAsync().ConfigureAwait(true); e.Handled = 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); } } }