Add host activity and DB browser, and keep dialogs, drag-drop, and idle maintenance responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 02:03:52 +02:00
parent b72c375e87
commit b33a78dbbe
45 changed files with 3254 additions and 171 deletions

View File

@@ -0,0 +1,78 @@
<Window x:Class="Explorer.App.DatabaseWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Database"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="720" Width="1100"
MinHeight="480" MinWidth="800"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="12">
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="30" Click="OnClose" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close DB" MinWidth="80" Height="28"
Command="{Binding CloseDatabaseCommand}" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open file…" MinWidth="90" Height="28"
Click="OnOpenFile" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open Workbench index" MinWidth="150" Height="28"
Command="{Binding OpenWorkbenchIndexCommand}" Margin="6,0,0,0"/>
<StackPanel>
<TextBlock Text="{Binding PathLabel}" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding ModeLabel}" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
</StackPanel>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="↻" Width="28" Height="26"
Command="{Binding RefreshTablesCommand}" ToolTip="Refresh tables"/>
<TextBlock Text="Tables" FontWeight="SemiBold" VerticalAlignment="Center"/>
</DockPanel>
<ListBox ItemsSource="{Binding Tables}" SelectedItem="{Binding SelectedTable}"/>
</DockPanel>
<DockPanel Grid.Column="2">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Run SQL" MinWidth="80" Height="28"
Command="{Binding RunSqlCommand}" Margin="8,0,0,0"/>
<TextBox Text="{Binding Sql, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="56" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
FontFamily="Consolas"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<TextBlock DockPanel.Dock="Left" Text="{Binding PageLabel}" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}" Margin="0,0,12,0"/>
<Button DockPanel.Dock="Left" Content="Prev" MinWidth="60" Height="26"
Command="{Binding PrevPageCommand}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Next" MinWidth="60" Height="26"
Command="{Binding NextPageCommand}" Margin="0,0,12,0"/>
<Button DockPanel.Dock="Left" Content="Edit cell…" MinWidth="80" Height="26"
Click="OnEditCell" IsEnabled="{Binding CanWrite}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Insert row…" MinWidth="90" Height="26"
Click="OnInsertRow" IsEnabled="{Binding CanWrite}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Delete row" MinWidth="80" Height="26"
Command="{Binding DeleteSelectedRowCommand}" IsEnabled="{Binding CanWrite}"/>
</DockPanel>
<ListView x:Name="GridViewHost"
ItemsSource="{Binding Rows}"
SelectedItem="{Binding SelectedRow}"
MouseDoubleClick="OnRowDoubleClick">
<ListView.View>
<GridView x:Name="ResultGrid"/>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,159 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using Explorer.Presentation.ViewModels;
using Microsoft.Win32;
namespace Explorer.App;
public partial class DatabaseWindow : Window
{
public DatabaseWindow(DatabaseViewerViewModel vm)
{
InitializeComponent();
DataContext = vm;
ViewModel = vm;
vm.GridChanged += (_, _) => RebuildColumns();
ModelessWindowClose.EnableEscape(this);
Closed += async (_, _) => await vm.DisposeAsync().ConfigureAwait(true);
Loaded += async (_, _) => await vm.OpenIndexAsync(preferWrite: false).ConfigureAwait(true);
}
public DatabaseViewerViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void RebuildColumns()
{
ResultGrid.Columns.Clear();
for (var i = 0; i < ViewModel.Columns.Count; i++)
{
var index = i;
var header = ViewModel.Columns[i];
ResultGrid.Columns.Add(new GridViewColumn
{
Header = header,
Width = header.Equals("_rowid_", StringComparison.OrdinalIgnoreCase) ? 70 : 140,
DisplayMemberBinding = new Binding($"Cells[{index}]")
});
}
}
private void OnOpenFile(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Open SQLite database",
Filter = "SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*"
};
if (dlg.ShowDialog(this) == true)
{
var write = MessageBox.Show(
this,
"Open for writing? Choose No for read-only.",
"Database",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question);
if (write == MessageBoxResult.Cancel)
{
return;
}
_ = ViewModel.OpenPathAsync(dlg.FileName, preferWrite: write == MessageBoxResult.Yes);
}
}
private async void OnEditCell(object sender, RoutedEventArgs e)
=> await EditSelectedCellAsync().ConfigureAwait(true);
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
=> await EditSelectedCellAsync().ConfigureAwait(true);
private async Task EditSelectedCellAsync()
{
if (!ViewModel.CanWrite || ViewModel.SelectedRow is null || string.IsNullOrWhiteSpace(ViewModel.SelectedTable))
{
return;
}
var columns = ViewModel.EditableColumns();
if (columns.Count == 0)
{
return;
}
var column = Prompt("Column to edit", string.Join(", ", columns.Take(8)) + (columns.Count > 8 ? "…" : ""), columns[0]);
if (column is null || !columns.Contains(column, StringComparer.OrdinalIgnoreCase))
{
return;
}
var index = ViewModel.Columns.ToList().FindIndex(c => c.Equals(column, StringComparison.OrdinalIgnoreCase));
var current = index >= 0 && index < ViewModel.SelectedRow.Cells.Count
? ViewModel.SelectedRow.Cells[index]
: "";
var next = Prompt("New value for " + column, "Leave empty for NULL.", current);
if (next is null)
{
return;
}
try
{
await ViewModel.UpdateSelectedCellAsync(column, string.IsNullOrEmpty(next) ? null : next)
.ConfigureAwait(true);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Database", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async void OnInsertRow(object sender, RoutedEventArgs e)
{
if (!ViewModel.CanWrite || string.IsNullOrWhiteSpace(ViewModel.SelectedTable))
{
return;
}
var columns = ViewModel.EditableColumns();
if (columns.Count == 0)
{
MessageBox.Show(this, "Load a table first so columns are known.", "Database");
return;
}
var values = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var column in columns)
{
var value = Prompt("Value for " + column, "Cancel skips this column. Empty = NULL.", "");
if (value is null)
{
continue;
}
values[column] = string.IsNullOrEmpty(value) ? null : value;
}
if (values.Count == 0)
{
return;
}
try
{
await ViewModel.InsertRowAsync(values).ConfigureAwait(true);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Database", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private string? Prompt(string title, string message, string initial)
{
var dlg = new PromptWindow(title, message, initial) { Owner = this };
return dlg.ShowDialog() == true ? dlg.Value : null;
}
}

View File

@@ -9,9 +9,9 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel>
<DockPanel DockPanel.Dock="Bottom" Margin="16,8,16,16">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open file" MinWidth="88" Height="32" Click="OnOpenFile"
x:Name="OpenFileButton" Margin="8,0,0,0"/>
x:Name="OpenFileButton" IsEnabled="False" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Reload" MinWidth="88" Height="32" Click="OnReload" Margin="8,0,0,0"/>
<TextBlock x:Name="SourceLabel" VerticalAlignment="Center" TextWrapping="Wrap"
Foreground="{DynamicResource FgMuted}"/>

View File

@@ -11,14 +11,19 @@ public partial class DocumentationWindow : Window
{
private FlowDocument? _document;
private bool _suppressToc;
private int _renderGeneration;
public DocumentationWindow()
{
InitializeComponent();
Loaded += (_, _) => Render();
ModelessWindowClose.EnableEscape(this);
Loaded += async (_, _) => await RenderAsync().ConfigureAwait(true);
}
private void OnReload(object sender, RoutedEventArgs e) => Render();
private void OnClose(object sender, RoutedEventArgs e) => Close();
private async void OnReload(object sender, RoutedEventArgs e)
=> await RenderAsync().ConfigureAwait(true);
private void OnOpenFile(object sender, RoutedEventArgs e)
{
@@ -44,10 +49,35 @@ public partial class DocumentationWindow : Window
}
}
private void Render()
private async Task RenderAsync()
{
var loaded = DocumentationLoader.Load();
var parsed = MarkdownParser.Parse(loaded.Markdown);
var generation = Interlocked.Increment(ref _renderGeneration);
SourceLabel.Text = "Loading…";
LoadedDocumentation loaded;
MarkdownDocument parsed;
try
{
(loaded, parsed) = await Task.Run(() =>
{
var document = DocumentationLoader.Load();
return (document, MarkdownParser.Parse(document.Markdown));
}).ConfigureAwait(true);
}
catch (Exception ex)
{
if (generation == _renderGeneration)
{
SourceLabel.Text = "Could not load documentation: " + ex.Message;
}
return;
}
if (generation != _renderGeneration || !IsLoaded)
{
return;
}
var brushes = new DocumentationBrushes(
Brush("Fg"),
Brush("FgMuted"),

View File

@@ -9,7 +9,7 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Commit…" MinWidth="88" Height="32"
Click="OnCommit" IsEnabled="{Binding CanCommit}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Push" MinWidth="72" Height="32"

View File

@@ -12,10 +12,13 @@ public partial class GitChangesWindow : Window
InitializeComponent();
DataContext = vm;
ViewModel = vm;
ModelessWindowClose.EnableEscape(this);
}
public GitChangesViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
=> await ShowDiffAsync().ConfigureAwait(true);

View File

@@ -9,7 +9,7 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<Button DockPanel.Dock="Bottom" Content="Close" MinWidth="88" Height="32" HorizontalAlignment="Right"
IsCancel="True" Margin="0,12,0,0"/>
Click="OnClose" Margin="0,12,0,0"/>
<TextBlock DockPanel.Dock="Top" Text="{Binding EmptyText}" Margin="0,0,0,8"
Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolVis}}"/>

View File

@@ -10,5 +10,8 @@ public partial class GitDiffWindow : Window
InitializeComponent();
DataContext = vm;
Title = vm.Title;
ModelessWindowClose.EnableEscape(this);
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
}

View File

@@ -0,0 +1,130 @@
<Window x:Class="Explorer.App.HostActivityMonitorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Host activity"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="640" Width="960"
MinHeight="420" MinWidth="720"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="CanResize"
Background="{DynamicResource Bg}"
Foreground="{DynamicResource Fg}"
UseLayoutRounding="True"
SnapsToDevicePixels="True">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome CaptionHeight="40"
ResizeBorderThickness="6"
GlassFrameThickness="0"
CornerRadius="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<DockPanel>
<Border DockPanel.Dock="Top" Height="40" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0" IsHitTestVisible="False">
<Image Width="20" Height="20" Margin="0,0,8,0" VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
Source="pack://application:,,,/Assets/explorer-workbench-20.png"/>
<TextBlock Text="Host activity" FontWeight="SemiBold" VerticalAlignment="Center"
Foreground="{DynamicResource Fg}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource CaptionButton}" Content="─" Click="OnMinimize" ToolTip="Minimize"/>
<Button x:Name="MaxRestoreButton" Style="{StaticResource CaptionButton}" Content="☐"
Click="OnMaxRestore" ToolTip="Maximize"/>
<Button Style="{StaticResource CaptionCloseButton}" Content="✕" Click="OnClose" ToolTip="Close"/>
</StackPanel>
</Grid>
</Border>
<DockPanel Margin="14">
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="30" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Run maintenance now" MinWidth="140" Height="30"
Command="{Binding RunMaintenanceNowCommand}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Refresh" MinWidth="88" Height="30"
Command="{Binding RefreshCommand}" Margin="8,0,0,0"/>
<TextBlock VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
<Run Text="{Binding Status, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding IsLive, Mode=OneWay, StringFormat=Live: {0}}"/>
</TextBlock>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Summary}" FontWeight="SemiBold" FontSize="15"
TextWrapping="Wrap" Margin="0,0,0,10"/>
<UniformGrid DockPanel.Dock="Top" Rows="2" Columns="2" Margin="0,0,0,10">
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="0,0,6,6">
<StackPanel>
<TextBlock Text="Maintenance" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding MaintenanceText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="6,0,0,6">
<StackPanel>
<TextBlock Text="Indexing" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding IndexingText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="0,6,6,0">
<StackPanel>
<TextBlock Text="Hashing" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding HashText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="6,6,0,0">
<StackPanel>
<TextBlock Text="Transfers" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding TransferText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</UniformGrid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Current indexing jobs" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ListView ItemsSource="{Binding Jobs}" BorderBrush="{DynamicResource Stroke}"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,2">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Detail}" Foreground="{DynamicResource FgMuted}" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Origin}" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</DockPanel>
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="{Binding LogCaption}" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ListView x:Name="EventList" ItemsSource="{Binding Events}" BorderBrush="{DynamicResource Stroke}"
ScrollViewer.ScrollChanged="OnEventScrollChanged"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListView.View>
<GridView>
<GridViewColumn Header="Time" Width="70" DisplayMemberBinding="{Binding Time}"/>
<GridViewColumn Header="Area" Width="100" DisplayMemberBinding="{Binding Category}"/>
<GridViewColumn Header="Detail" Width="440" DisplayMemberBinding="{Binding Message}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,139 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class HostActivityMonitorWindow : Window
{
private bool _stickToEnd = true;
private bool _scrollQueued;
private ScrollViewer? _eventScroll;
public HostActivityMonitorWindow(HostActivityMonitorViewModel vm)
{
InitializeComponent();
DataContext = vm;
ViewModel = vm;
ModelessWindowClose.EnableEscape(this);
StateChanged += (_, _) => SyncMaxRestoreButton();
vm.EventsReplaced += OnEventsReplaced;
Closed += (_, _) =>
{
vm.EventsReplaced -= OnEventsReplaced;
vm.Dispose();
};
Loaded += (_, _) =>
{
SyncMaxRestoreButton();
_eventScroll = FindScrollViewer(EventList);
vm.Start();
};
}
public HostActivityMonitorViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void OnMaxRestore(object sender, RoutedEventArgs e) => ToggleMaximized();
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton != MouseButton.Left)
{
return;
}
if (e.ClickCount == 2)
{
ToggleMaximized();
return;
}
DragMove();
}
private void ToggleMaximized()
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
SyncMaxRestoreButton();
}
private void SyncMaxRestoreButton()
=> MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
private void OnEventScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (e.OriginalSource is not ScrollViewer viewer)
{
return;
}
_eventScroll = viewer;
if (e.ExtentHeightChange != 0)
{
return;
}
// Only treat intentional user scroll as leaving the live tail.
if (e.VerticalChange == 0)
{
return;
}
_stickToEnd = viewer.VerticalOffset >= viewer.ScrollableHeight - 8;
}
private void OnEventsReplaced(object? sender, EventArgs e)
{
if (!_stickToEnd || _scrollQueued)
{
return;
}
_scrollQueued = true;
Dispatcher.BeginInvoke(() =>
{
_scrollQueued = false;
if (!_stickToEnd)
{
return;
}
_eventScroll ??= FindScrollViewer(EventList);
if (_eventScroll is not null)
{
_eventScroll.ScrollToEnd();
}
else if (EventList.Items.Count > 0)
{
EventList.ScrollIntoView(EventList.Items[^1]);
}
}, DispatcherPriority.Background);
}
private static ScrollViewer? FindScrollViewer(DependencyObject root)
{
if (root is ScrollViewer scroll)
{
return scroll;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var child = VisualTreeHelper.GetChild(root, i);
var found = FindScrollViewer(child);
if (found is not null)
{
return found;
}
}
return null;
}
}

View File

@@ -183,6 +183,9 @@
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
</MenuItem>
<MenuItem Header="_Development">
<MenuItem Header="_Host activity…" Click="OnHostActivity"/>
<MenuItem Header="_Database…" Click="OnDatabase"/>
<Separator/>
<MenuItem Header="View _changes…" Click="OnGitChanges"
IsEnabled="{Binding ShowGitActions}"/>
<MenuItem Header="_Commit…" Click="OnGitCommit"
@@ -986,66 +989,91 @@
</Border>
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Duplicates.IsOpen, Converter={StaticResource BoolVis}}">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="48" Padding="16">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
Margin="48" Padding="16" VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Duplicates.CloseCommand}"/>
<TextBlock Text="Duplicates" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Grid.Row="1" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Show intentional" IsChecked="{Binding Duplicates.ShowIntentional}" Margin="0,0,16,0"/>
<CheckBox Content="Show hard links" IsChecked="{Binding Duplicates.ShowHardlinks}"/>
</StackPanel>
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" IsIndeterminate="True"
<ProgressBar Grid.Row="3" Margin="0,0,0,8" IsIndeterminate="True"
Visibility="{Binding Duplicates.IsBusy, Converter={StaticResource BoolVis}}"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Duplicates.Groups}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Content="Mark as intentional" Margin="0,0,6,0"
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
<Button Content="Mark as accidental"
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<TextBlock Foreground="{DynamicResource Fg}" FontWeight="SemiBold">
<Run Text="{Binding ClassLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding SizeLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding Summary, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding WastedLabel, Mode=OneWay}"/>
</TextBlock>
</DockPanel>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2">
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ListBox Grid.Row="4"
ItemsSource="{Binding Duplicates.Groups}"
Background="Transparent"
BorderThickness="0"
Padding="0"
HorizontalContentAlignment="Stretch"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Visible"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter HorizontalAlignment="Stretch"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Content="Mark as intentional" Margin="0,0,6,0"
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
<Button Content="Mark as accidental"
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Header, Mode=OneWay}" Foreground="{DynamicResource Fg}" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2">
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Border>
</Grid>

View File

@@ -18,11 +18,14 @@ public partial class MainWindow : Window
{
private DocumentationWindow? _docs;
private GitChangesWindow? _gitChanges;
private HostActivityMonitorWindow? _hostActivity;
private DatabaseWindow? _database;
private Point _dragStart;
private bool _dragPending;
private MouseButton _dragButton;
private FolderItemViewModel? _dragItem;
private ListView? _dragList;
private bool _draggingFromHere;
private FolderItemViewModel[] _dragSelection = [];
private bool _dragFromMultiSelect;
private bool _syncingSelection;
@@ -661,7 +664,7 @@ public partial class MainWindow : Window
}
_suppressItemContextMenu = false;
_dragStart = e.GetPosition(null);
_dragStart = e.GetPosition(this);
_dragPending = true;
_dragButton = e.ChangedButton;
_dragList = sender as ListView;
@@ -739,11 +742,12 @@ public partial class MainWindow : Window
&& dest is not null
&& NavigationTreeViewModel.PathsEqual(hover.FullPath, dest)
&& !DragDropPolicy.IsInvalidTarget(files, dest)
&& !IsRedundantLeftDrop(files, dest)
? hover
: null;
SetListDropTarget(folderTarget);
SetTreeDropTarget(null);
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest) || IsRedundantLeftDrop(files, dest))
{
e.Effects = DragDropEffects.None;
e.Handled = true;
@@ -786,6 +790,11 @@ public partial class MainWindow : Window
return;
}
if (IsRedundantLeftDrop(files, dest))
{
return;
}
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
@@ -863,9 +872,8 @@ public partial class MainWindow : Window
return;
}
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
var pos = e.GetPosition(this);
if (!DragMovedEnough(pos, DragDropPolicy.DragStartDistance))
{
return;
}
@@ -887,10 +895,18 @@ public partial class MainWindow : Window
_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;
ClearDropTargets();
_draggingFromHere = true;
try
{
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
}
finally
{
_draggingFromHere = false;
_sourceRightDrag = false;
_incomingRightDrag = false;
ClearDropTargets();
}
}
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
@@ -1017,16 +1033,58 @@ public partial class MainWindow : Window
private string? ResolveDropDirectory(ListView list, DragEventArgs e)
{
var item = HitTestFolderItem(list, e.GetPosition(list));
if (item is { IsDirectory: true })
if (!_draggingFromHere)
{
if (item is { IsDirectory: true })
{
return item.FullPath;
}
return CurrentListDirectory(list);
}
var pos = e.GetPosition(this);
if (item is { IsDirectory: true }
&& DragMovedEnough(pos, FolderDropDistance(list)))
{
return item.FullPath;
}
if (!ReferenceEquals(list, _dragList)
&& !DragMovedEnough(pos, DragDropPolicy.DropCrossViewDistance))
{
return null;
}
return CurrentListDirectory(list);
}
private string? CurrentListDirectory(ListView list)
{
var tab = Vm.ActiveTab;
var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath;
return LocationRoots.IsVirtual(path) ? null : path;
}
private double FolderDropDistance(ListView list)
=> ReferenceEquals(list, _dragList)
? DragDropPolicy.DropIntoItemDistance
: DragDropPolicy.DropCrossViewDistance;
private bool DragMovedEnough(Point current, double preferred)
=> DragDropPolicy.ExceedsDistance(
current.X - _dragStart.X,
current.Y - _dragStart.Y,
Math.Max(preferred, SystemDragMinimum));
private static double SystemDragMinimum
=> Math.Max(SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance);
private bool IsRedundantLeftDrop(IReadOnlyList<string> files, string dest)
=> !_sourceRightDrag
&& !_incomingRightDrag
&& DragDropPolicy.AllAlreadyInDirectory(files, dest);
private static FolderItemViewModel? HitTestFolderItem(DependencyObject? origin, Point point)
{
if (origin is not Visual visual)
@@ -1178,6 +1236,11 @@ public partial class MainWindow : Window
return;
}
if (IsRedundantLeftDrop(files, dest))
{
return;
}
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
@@ -1848,6 +1911,48 @@ public partial class MainWindow : Window
private void OnAbout(object sender, RoutedEventArgs e)
=> new AboutWindow { Owner = this }.ShowDialog();
private void OnHostActivity(object sender, RoutedEventArgs e)
{
if (_hostActivity is { IsVisible: true })
{
_hostActivity.Activate();
return;
}
var vm = Vm.CreateHostActivityMonitorViewModel();
if (vm is null)
{
MessageBox.Show(this, "Host activity is unavailable until the background host is connected.",
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
_hostActivity = new HostActivityMonitorWindow(vm) { Owner = this };
_hostActivity.Closed += (_, _) => _hostActivity = null;
_hostActivity.Show();
}
private void OnDatabase(object sender, RoutedEventArgs e)
{
if (_database is { IsVisible: true })
{
_database.Activate();
return;
}
var vm = Vm.CreateDatabaseViewerViewModel();
if (vm is null)
{
MessageBox.Show(this, "Database tools are unavailable.",
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
_database = new DatabaseWindow(vm) { Owner = this };
_database.Closed += (_, _) => _database = null;
_database.Show();
}
private async void OnGitChanges(object sender, RoutedEventArgs e)
{
if (_gitChanges is { IsVisible: true })

View File

@@ -0,0 +1,23 @@
using System.Windows;
using System.Windows.Input;
namespace Explorer.App;
/// <summary>
/// Close helpers for windows opened with <see cref="Window.Show"/>.
/// <see cref="Button.IsCancel"/> sets <see cref="Window.DialogResult"/>, which throws on modeless windows.
/// </summary>
internal static class ModelessWindowClose
{
public static void EnableEscape(Window window)
=> window.PreviewKeyDown += (_, e) =>
{
if (e.Key != Key.Escape)
{
return;
}
window.Close();
e.Handled = true;
};
}