Show the window before slow location probes and wrap git.exe for commit, diff, and merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 16:31:56 +02:00
parent a3c54bbb03
commit 9efb306979
42 changed files with 3184 additions and 77 deletions

View File

@@ -35,11 +35,21 @@ public partial class App : System.Windows.Application
.Build();
var vm = _host.Services.GetRequiredService<MainViewModel>();
await vm.InitializeAsync().ConfigureAwait(true);
await _host.StartAsync().ConfigureAwait(true);
var window = _host.Services.GetRequiredService<MainWindow>();
vm.PrepareUi();
window.DataContext = vm;
window.Show();
try
{
await vm.InitializeAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error(ex, "Startup initialization failed");
vm.Footer = "Started with errors. See logs.";
}
await _host.StartAsync().ConfigureAwait(true);
}
protected override async void OnExit(ExitEventArgs e)

View File

@@ -42,7 +42,9 @@ public static class AppServices
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IGitStatusProvider, WindowsGitStatusProvider>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
@@ -115,7 +117,7 @@ public sealed class WatcherHostedService : BackgroundService
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(stoppingToken).ConfigureAwait(false);
await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);

View File

@@ -0,0 +1,80 @@
<Window x:Class="Explorer.App.GitChangesWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Git changes"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="620" Width="920"
MinHeight="400" MinWidth="640"
WindowStartupLocation="CenterOwner"
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="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"
Click="OnPush" IsEnabled="{Binding CanNetwork}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Pull" MinWidth="72" Height="32"
Click="OnPull" IsEnabled="{Binding CanNetwork}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Fetch" MinWidth="72" Height="32"
Click="OnFetch" IsEnabled="{Binding CanNetwork}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Refresh" MinWidth="88" Height="32"
Command="{Binding RefreshCommand}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}"
TextWrapping="Wrap"/>
</DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<TextBlock Text="{Binding Summary}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding RepoRoot}" Margin="0,4,0,0" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<WrapPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button Content="View diff" MinWidth="88" Height="32" Click="OnDiff"
IsEnabled="{Binding CanDiff}" Margin="0,0,8,8"/>
<Button Content="Open in Cursor" MinWidth="120" Height="32"
Command="{Binding OpenSelectedInCursorCommand}"
IsEnabled="{Binding CanOpenInCursor}" Margin="0,0,8,8"/>
<Button Content="Stage" MinWidth="72" Height="32" Click="OnStage"
IsEnabled="{Binding CanStage}" Margin="0,0,8,8"/>
<Button Content="Unstage" MinWidth="72" Height="32" Click="OnUnstage"
IsEnabled="{Binding CanUnstage}" Margin="0,0,8,8"/>
<Button Content="Discard…" MinWidth="88" Height="32" Click="OnDiscard"
IsEnabled="{Binding CanDiscard}" Margin="0,0,16,8"/>
<Button Content="Use ours" MinWidth="88" Height="32" Click="OnUseOurs"
IsEnabled="{Binding CanResolve}" Margin="0,0,8,8"/>
<Button Content="Use theirs" MinWidth="88" Height="32" Click="OnUseTheirs"
IsEnabled="{Binding CanResolve}" Margin="0,0,8,8"/>
<Button Content="Mark resolved" MinWidth="110" Height="32" Click="OnMarkResolved"
IsEnabled="{Binding CanResolve}" Margin="0,0,16,8"/>
<Button Content="Abort" MinWidth="72" Height="32" Click="OnAbort"
IsEnabled="{Binding CanAbort}" Margin="0,0,8,8"/>
<Button Content="Continue" MinWidth="88" Height="32" Click="OnContinue"
IsEnabled="{Binding CanContinue}" Margin="0,0,8,8"/>
</WrapPanel>
<ListView ItemsSource="{Binding Changes}" SelectedItem="{Binding Selected}"
MouseDoubleClick="OnRowDoubleClick">
<ListView.ContextMenu>
<ContextMenu>
<MenuItem Header="View diff" Click="OnDiff" IsEnabled="{Binding CanDiff}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenSelectedInCursorCommand}"
IsEnabled="{Binding CanOpenInCursor}"/>
<Separator/>
<MenuItem Header="Stage" Click="OnStage" IsEnabled="{Binding CanStage}"/>
<MenuItem Header="Unstage" Click="OnUnstage" IsEnabled="{Binding CanUnstage}"/>
<MenuItem Header="Discard…" Click="OnDiscard" IsEnabled="{Binding CanDiscard}"/>
<Separator/>
<MenuItem Header="Use ours" Click="OnUseOurs" IsEnabled="{Binding CanResolve}"/>
<MenuItem Header="Use theirs" Click="OnUseTheirs" IsEnabled="{Binding CanResolve}"/>
<MenuItem Header="Mark resolved" Click="OnMarkResolved" IsEnabled="{Binding CanResolve}"/>
</ContextMenu>
</ListView.ContextMenu>
<ListView.View>
<GridView>
<GridViewColumn Header="Kind" Width="100" DisplayMemberBinding="{Binding KindLabel}"/>
<GridViewColumn Header="Change" Width="110" DisplayMemberBinding="{Binding ChangeLabel}"/>
<GridViewColumn Header="Path" Width="620" DisplayMemberBinding="{Binding DisplayPath}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Window>

View File

@@ -0,0 +1,147 @@
using System.Windows;
using System.Windows.Input;
using Explorer.Domain;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class GitChangesWindow : Window
{
public GitChangesWindow(GitChangesViewModel vm)
{
InitializeComponent();
DataContext = vm;
ViewModel = vm;
}
public GitChangesViewModel ViewModel { get; }
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
=> await ShowDiffAsync().ConfigureAwait(true);
private async void OnDiff(object sender, RoutedEventArgs e)
=> await ShowDiffAsync().ConfigureAwait(true);
private async Task ShowDiffAsync()
{
var diff = await ViewModel.DiffSelectedAsync().ConfigureAwait(true);
if (diff is null)
{
return;
}
new GitDiffWindow(new GitDiffViewModel(diff)) { Owner = this }.Show();
}
private async void OnCommit(object sender, RoutedEventArgs e)
{
var vm = ViewModel.CreateCommitViewModel();
if (vm is null)
{
return;
}
var dlg = new GitCommitWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true && Owner is MainWindow main)
{
await main.AfterGitMutationAsync().ConfigureAwait(true);
await ViewModel.RefreshAsync().ConfigureAwait(true);
}
}
private async void OnFetch(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.FetchAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnPull(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.PullAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnPush(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.PushAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnStage(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.StageSelectedAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnUnstage(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.UnstageSelectedAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnDiscard(object sender, RoutedEventArgs e)
{
if (ViewModel.Selected is null)
{
return;
}
var path = ViewModel.Selected.DisplayPath;
var choice = MessageBox.Show(
this,
$"Discard local changes to {path}?",
"Git",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (choice != MessageBoxResult.Yes)
{
return;
}
await ShowGitResultAsync(await ViewModel.DiscardSelectedAsync().ConfigureAwait(true)).ConfigureAwait(true);
}
private async void OnUseOurs(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.UseOursAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnUseTheirs(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.UseTheirsAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnMarkResolved(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.MarkResolvedAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async void OnAbort(object sender, RoutedEventArgs e)
{
var choice = MessageBox.Show(
this,
$"Abort the {OperationName()} and restore the previous state?",
"Git",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (choice != MessageBoxResult.Yes)
{
return;
}
await ShowGitResultAsync(await ViewModel.AbortAsync().ConfigureAwait(true)).ConfigureAwait(true);
}
private async void OnContinue(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await ViewModel.ContinueAsync().ConfigureAwait(true)).ConfigureAwait(true);
private async Task ShowGitResultAsync(GitCommandResult result)
{
if (result.Succeeded)
{
if (Owner is MainWindow main)
{
await main.AfterGitMutationAsync().ConfigureAwait(true);
}
return;
}
if (Owner is MainWindow owner)
{
await owner.ShowGitResultAsync(result).ConfigureAwait(true);
return;
}
MessageBox.Show(this, result.DisplayMessage, "Git", MessageBoxButton.OK, MessageBoxImage.Warning);
}
private string OperationName()
=> ViewModel.Operation switch
{
GitOperationKind.Merge => "merge",
GitOperationKind.Rebase => "rebase",
GitOperationKind.CherryPick => "cherry-pick",
GitOperationKind.Revert => "revert",
_ => "Git operation"
};
}

View File

@@ -0,0 +1,43 @@
<Window x:Class="Explorer.App.GitCommitWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Commit"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="520" Width="720"
MinHeight="360" MinWidth="520"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Commit" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding CommitCommand}" IsEnabled="{Binding CanCommit}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}"
TextWrapping="Wrap"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding RepoRoot}" Margin="0,0,0,8"
Foreground="{DynamicResource FgMuted}" TextTrimming="CharacterEllipsis"/>
<TextBlock DockPanel.Dock="Top" Text="Message" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox DockPanel.Dock="Top" Text="{Binding Message, UpdateSourceTrigger=PropertyChanged}"
AcceptsReturn="True" Height="88" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
Margin="0,0,0,12"/>
<TextBlock DockPanel.Dock="Top" Text="Files" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<ListView ItemsSource="{Binding Files}">
<ListView.View>
<GridView>
<GridViewColumn Width="36">
<GridViewColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding Include, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding CanInclude}" VerticalAlignment="Center"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Kind" Width="90" DisplayMemberBinding="{Binding KindLabel}"/>
<GridViewColumn Header="Change" Width="100" DisplayMemberBinding="{Binding ChangeLabel}"/>
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding DisplayPath}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Window>

View File

@@ -0,0 +1,24 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class GitCommitWindow : Window
{
public GitCommitWindow(GitCommitViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.CloseRequested += (_, _) =>
{
try
{
DialogResult = true;
}
catch (InvalidOperationException)
{
Close();
}
};
}
}

View File

@@ -0,0 +1,60 @@
<Window x:Class="Explorer.App.GitDiffWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="{Binding Title}"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="640" Width="860"
MinHeight="360" MinWidth="520"
WindowStartupLocation="CenterOwner"
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"/>
<TextBlock DockPanel.Dock="Top" Text="{Binding EmptyText}" Margin="0,0,0,8"
Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolVis}}"/>
<ListBox ItemsSource="{Binding Lines}" FontFamily="Consolas" FontSize="13"
Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Focusable" Value="False"/>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Text}" FontFamily="Consolas" Padding="8,1" TextWrapping="NoWrap">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Kind}" Value="Added">
<Setter Property="Foreground" Value="{DynamicResource GitDiffAdded}"/>
<Setter Property="Background" Value="{DynamicResource GitDiffAddedBg}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Kind}" Value="Removed">
<Setter Property="Foreground" Value="{DynamicResource GitDiffRemoved}"/>
<Setter Property="Background" Value="{DynamicResource GitDiffRemovedBg}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Kind}" Value="Hunk">
<Setter Property="Foreground" Value="{DynamicResource GitDiffHunk}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Kind}" Value="Meta">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Window>

View File

@@ -0,0 +1,14 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class GitDiffWindow : Window
{
public GitDiffWindow(GitDiffViewModel vm)
{
InitializeComponent();
DataContext = vm;
Title = vm.Title;
}
}

View File

@@ -93,6 +93,20 @@
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
</MenuItem>
<MenuItem Header="_Development">
<MenuItem Header="View _changes…" Click="OnGitChanges"
IsEnabled="{Binding ShowGitActions}"/>
<MenuItem Header="_Commit…" Click="OnGitCommit"
IsEnabled="{Binding ShowGitActions}"/>
<Separator/>
<MenuItem Header="_Fetch" Click="OnGitFetch"
IsEnabled="{Binding ShowGitActions}"/>
<MenuItem Header="P_ull (fast-forward)" Click="OnGitPull"
IsEnabled="{Binding ShowGitActions}"/>
<MenuItem Header="Pull (_merge)" Click="OnGitPullMerge"
IsEnabled="{Binding ShowGitActions}"/>
<MenuItem Header="_Push" Click="OnGitPush"
IsEnabled="{Binding ShowGitActions}"/>
<Separator/>
<MenuItem Header="Open _terminal here" Command="{Binding OpenTerminalCommand}"
IsEnabled="{Binding ShowOpenTerminal}"/>
<MenuItem Header="Open in _Cursor" Command="{Binding OpenInCursorCommand}"
@@ -460,6 +474,18 @@
<Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
<MenuItem Header="View changes…" Click="OnGitChanges"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Commit…" Click="OnGitCommit"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Fetch" Click="OnGitFetch"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Pull (fast-forward)" Click="OnGitPull"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Pull (merge)" Click="OnGitPullMerge"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Push" Click="OnGitPush"
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"

View File

@@ -16,6 +16,7 @@ namespace Explorer.App;
public partial class MainWindow : Window
{
private DocumentationWindow? _docs;
private GitChangesWindow? _gitChanges;
private Point _dragStart;
private bool _dragPending;
private MouseButton _dragButton;
@@ -39,6 +40,7 @@ public partial class MainWindow : Window
{
_wiredVm.InlineRenameRequested -= OnInlineRenameRequested;
_wiredVm.PropertyChanged -= OnViewModelPropertyChanged;
_wiredVm.WorkspaceChanged -= OnWorkspaceChanged;
}
_wiredVm = DataContext as MainViewModel;
@@ -46,6 +48,7 @@ public partial class MainWindow : Window
{
_wiredVm.InlineRenameRequested += OnInlineRenameRequested;
_wiredVm.PropertyChanged += OnViewModelPropertyChanged;
_wiredVm.WorkspaceChanged += OnWorkspaceChanged;
RestoreLayout(_wiredVm);
_ = _wiredVm.RefreshUndoRenameAsync();
}
@@ -63,6 +66,9 @@ public partial class MainWindow : Window
}
}
private void OnWorkspaceChanged(object? sender, EventArgs e)
=> _ = ReloadGitChangesIfOpenAsync();
private void OnInlineRenameRequested(object? sender, string path)
=> Dispatcher.BeginInvoke(() => BeginInlineRenameForPath(path), DispatcherPriority.Loaded);
@@ -1480,6 +1486,126 @@ public partial class MainWindow : Window
private void OnAbout(object sender, RoutedEventArgs e)
=> new AboutWindow { Owner = this }.ShowDialog();
private async void OnGitChanges(object sender, RoutedEventArgs e)
{
if (_gitChanges is { IsVisible: true })
{
_gitChanges.Activate();
await ReloadGitChangesIfOpenAsync().ConfigureAwait(true);
return;
}
var vm = Vm.CreateGitChangesViewModel();
_gitChanges = new GitChangesWindow(vm) { Owner = this };
_gitChanges.Closed += (_, _) => _gitChanges = null;
_gitChanges.Show();
await vm.LoadAsync(Vm.GitWorkspacePath()).ConfigureAwait(true);
}
private async void OnGitCommit(object sender, RoutedEventArgs e)
{
var vm = await Vm.CreateGitCommitViewModelAsync().ConfigureAwait(true);
if (vm is null)
{
return;
}
var dlg = new GitCommitWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
await AfterGitMutationAsync().ConfigureAwait(true);
}
}
private async void OnGitFetch(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await Vm.GitFetchAsync().ConfigureAwait(true), reloadChanges: true).ConfigureAwait(true);
private async void OnGitPull(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await Vm.GitPullAsync().ConfigureAwait(true), reloadChanges: true).ConfigureAwait(true);
private async void OnGitPullMerge(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await Vm.GitPullMergeAsync().ConfigureAwait(true), reloadChanges: true).ConfigureAwait(true);
private async void OnGitPush(object sender, RoutedEventArgs e)
=> await ShowGitResultAsync(await Vm.GitPushAsync().ConfigureAwait(true), reloadChanges: true).ConfigureAwait(true);
public async Task AfterGitMutationAsync()
{
await Vm.RefreshGitOverlaysAsync().ConfigureAwait(true);
await ReloadGitChangesIfOpenAsync().ConfigureAwait(true);
}
public Task ShowGitResultAsync(GitCommandResult result, bool reloadChanges = false)
=> ShowGitResultCoreAsync(result, reloadChanges);
public void ShowGitResult(GitCommandResult result, bool reloadChanges = false)
=> _ = ShowGitResultCoreAsync(result, reloadChanges);
private async Task ShowGitResultCoreAsync(GitCommandResult result, bool reloadChanges)
{
if (reloadChanges)
{
await ReloadGitChangesIfOpenAsync().ConfigureAwait(true);
}
if (result.Succeeded)
{
return;
}
if (result.SuggestMergePull)
{
var choice = MessageBox.Show(
this,
result.DisplayMessage + Environment.NewLine + Environment.NewLine
+ "Yes = pull with a merge commit" + Environment.NewLine
+ "No = open a terminal" + Environment.NewLine
+ "Cancel = dismiss",
"Git",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Warning);
if (choice == MessageBoxResult.Yes)
{
await ShowGitResultCoreAsync(await Vm.GitPullMergeAsync().ConfigureAwait(true), reloadChanges: true)
.ConfigureAwait(true);
}
else if (choice == MessageBoxResult.No)
{
Vm.OpenTerminal();
}
return;
}
if (result.SuggestTerminal)
{
var choice = MessageBox.Show(
this,
result.DisplayMessage + Environment.NewLine + Environment.NewLine + "Open a terminal in this repository?",
"Git",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (choice == MessageBoxResult.Yes)
{
Vm.OpenTerminal();
}
return;
}
MessageBox.Show(this, result.DisplayMessage, "Git", MessageBoxButton.OK, MessageBoxImage.Warning);
}
private Task ReloadGitChangesIfOpenAsync()
{
if (_gitChanges is not { IsVisible: true })
{
return Task.CompletedTask;
}
return _gitChanges.ViewModel.LoadAsync(Vm.GitWorkspacePath());
}
private void ShowDocumentation()
{
if (_docs is { IsVisible: true })

View File

@@ -72,7 +72,7 @@
<TextBlock Text="Git" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Repository badges use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. Explorer Workbench does not commit, push, or pull."/>
Text="Repository badges and Git actions use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. There is no branch UI or credential dialog."/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
<TextBox x:Name="GitPath"/>

View File

@@ -19,4 +19,9 @@
<SolidColorBrush x:Key="ScrollThumb" Color="#5A5A5E"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#7A7A7E"/>
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#9A9A9E"/>
<SolidColorBrush x:Key="GitDiffAdded" Color="#81C784"/>
<SolidColorBrush x:Key="GitDiffRemoved" Color="#E57373"/>
<SolidColorBrush x:Key="GitDiffHunk" Color="#64B5F6"/>
<SolidColorBrush x:Key="GitDiffAddedBg" Color="#1B3A24"/>
<SolidColorBrush x:Key="GitDiffRemovedBg" Color="#3A1B1B"/>
</ResourceDictionary>

View File

@@ -19,4 +19,9 @@
<SolidColorBrush x:Key="ScrollThumb" Color="#B0B0B0"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#8A8A8A"/>
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#6A6A6A"/>
<SolidColorBrush x:Key="GitDiffAdded" Color="#1B7F3A"/>
<SolidColorBrush x:Key="GitDiffRemoved" Color="#C62828"/>
<SolidColorBrush x:Key="GitDiffHunk" Color="#1565C0"/>
<SolidColorBrush x:Key="GitDiffAddedBg" Color="#E8F5E9"/>
<SolidColorBrush x:Key="GitDiffRemovedBg" Color="#FFEBEE"/>
</ResourceDictionary>

View File

@@ -0,0 +1,100 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed record GitCommitPlan
{
public IReadOnlyList<string> Paths { get; init; } = [];
public IReadOnlyList<string> SkippedHydration { get; init; } = [];
public IReadOnlyList<string> SkippedUnmerged { get; init; } = [];
public IReadOnlyList<string> SkippedDirectories { get; init; } = [];
public string? Error { get; init; }
public bool CanCommit => Error is null && Paths.Count > 0;
}
public static class GitCommitPlanner
{
public static GitCommitPlan Create(
string? message,
IReadOnlyList<GitChange> selected,
IReadOnlySet<string> hydrateRelativePaths,
IReadOnlySet<string> directoryRelativePaths,
bool operationInProgress)
{
if (operationInProgress)
{
return new GitCommitPlan
{
Error = "A merge or rebase is in progress. Continue or abort it first."
};
}
if (string.IsNullOrWhiteSpace(message))
{
return new GitCommitPlan { Error = "Enter a commit message." };
}
var paths = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var hydration = new List<string>();
var unmerged = new List<string>();
var directories = new List<string>();
foreach (var change in selected)
{
if (change.State == GitChangeState.Unmerged)
{
if (seen.Add(change.Path))
{
unmerged.Add(change.Path);
}
continue;
}
if (!seen.Add(change.Path))
{
continue;
}
if (hydrateRelativePaths.Contains(change.Path))
{
hydration.Add(change.Path);
continue;
}
if (directoryRelativePaths.Contains(change.Path) && !change.IsDeleted)
{
directories.Add(change.Path);
continue;
}
paths.Add(change.Path);
}
if (paths.Count == 0)
{
var error = hydration.Count > 0
? "Selected files are online-only. Staging them would download them."
: unmerged.Count > 0
? "Unmerged files cannot be committed here. Finish the merge in a terminal."
: directories.Count > 0
? "Folders are not staged. Select files."
: "Select at least one file to commit.";
return new GitCommitPlan
{
SkippedHydration = hydration,
SkippedUnmerged = unmerged,
SkippedDirectories = directories,
Error = error
};
}
return new GitCommitPlan
{
Paths = paths,
SkippedHydration = hydration,
SkippedUnmerged = unmerged,
SkippedDirectories = directories
};
}
}

View File

@@ -0,0 +1,86 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class GitDiffParser
{
private const int MaxLines = 8000;
public static GitDiff Parse(string output, string title)
{
if (string.IsNullOrWhiteSpace(output))
{
return new GitDiff { Title = title };
}
var lines = new List<GitDiffLine>();
var binary = false;
foreach (var raw in output.Replace("\r\n", "\n").Split('\n'))
{
if (lines.Count >= MaxLines)
{
lines.Add(new GitDiffLine(GitDiffLineKind.Meta, "… truncated"));
break;
}
var line = raw;
if (line.StartsWith("Binary files ", StringComparison.Ordinal)
|| line.StartsWith("GIT binary patch", StringComparison.Ordinal))
{
binary = true;
lines.Add(new GitDiffLine(GitDiffLineKind.Meta, line));
continue;
}
lines.Add(new GitDiffLine(Classify(line), line));
}
if (lines.Count > 0 && string.IsNullOrEmpty(lines[^1].Text))
{
lines.RemoveAt(lines.Count - 1);
}
return new GitDiff
{
Title = title,
Lines = lines,
IsBinary = binary
};
}
internal static GitDiffLineKind Classify(string line)
{
if (line.StartsWith("@@", StringComparison.Ordinal))
{
return GitDiffLineKind.Hunk;
}
if (line.StartsWith("+++", StringComparison.Ordinal)
|| line.StartsWith("---", StringComparison.Ordinal)
|| line.StartsWith("diff ", StringComparison.Ordinal)
|| line.StartsWith("index ", StringComparison.Ordinal)
|| line.StartsWith("new file", StringComparison.Ordinal)
|| line.StartsWith("deleted file", StringComparison.Ordinal)
|| line.StartsWith("old mode", StringComparison.Ordinal)
|| line.StartsWith("new mode", StringComparison.Ordinal)
|| line.StartsWith("similarity index", StringComparison.Ordinal)
|| line.StartsWith("rename from", StringComparison.Ordinal)
|| line.StartsWith("rename to", StringComparison.Ordinal)
|| line.StartsWith("\\ ", StringComparison.Ordinal))
{
return GitDiffLineKind.Meta;
}
if (line.StartsWith('+'))
{
return GitDiffLineKind.Added;
}
if (line.StartsWith('-'))
{
return GitDiffLineKind.Removed;
}
return GitDiffLineKind.Context;
}
}

View File

@@ -0,0 +1,54 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed record GitDiffRequest
{
public IReadOnlyList<string> Arguments { get; init; } = [];
public bool NeedsWorkingTree { get; init; }
public string? Error { get; init; }
}
public static class GitDiffPlanner
{
public static GitDiffRequest Create(GitChange change)
{
if (string.IsNullOrWhiteSpace(change.Path))
{
return new GitDiffRequest { Error = "Select a file to diff." };
}
var args = new List<string>
{
"--no-optional-locks",
"-c",
"core.quotepath=false",
"diff",
"--no-color",
"-U3"
};
switch (change.State)
{
case GitChangeState.Staged:
args.Add("--cached");
args.Add("--");
args.Add(change.Path);
return new GitDiffRequest { Arguments = args, NeedsWorkingTree = false };
case GitChangeState.Untracked:
args.Add("--no-index");
args.Add("--");
args.Add("/dev/null");
args.Add(change.Path);
return new GitDiffRequest { Arguments = args, NeedsWorkingTree = true };
default:
args.Add("--");
args.Add(change.Path);
return new GitDiffRequest
{
Arguments = args,
NeedsWorkingTree = !change.IsDeleted
};
}
}
}

View File

@@ -1,3 +1,4 @@
using System.Text;
using System.Text.RegularExpressions;
using Explorer.Domain;
@@ -20,6 +21,7 @@ public static class GitPorcelainParser
var behind = 0;
var modified = 0;
var untracked = 0;
var changes = new List<GitChange>();
foreach (var raw in output.Split(["\r\n", "\n"], StringSplitOptions.None))
{
var line = raw.TrimEnd();
@@ -60,6 +62,18 @@ public static class GitPorcelainParser
if (line.StartsWith("? ", StringComparison.Ordinal))
{
untracked++;
var path = ParsePath(RemainderAfter(line, 1));
if (path is not null)
{
changes.Add(new GitChange
{
Path = path,
Index = '?',
WorkTree = '?',
State = GitChangeState.Untracked
});
}
continue;
}
@@ -68,11 +82,37 @@ public static class GitPorcelainParser
continue;
}
if (line.StartsWith("1 ", StringComparison.Ordinal)
|| line.StartsWith("2 ", StringComparison.Ordinal)
|| line.StartsWith("u ", StringComparison.Ordinal))
if (line.StartsWith("u ", StringComparison.Ordinal))
{
modified++;
var xy = FieldAt(line, 1);
var path = ParsePath(RemainderAfter(line, 10));
if (path is not null)
{
changes.Add(new GitChange
{
Path = path,
Index = CodeAt(xy, 0),
WorkTree = CodeAt(xy, 1),
State = GitChangeState.Unmerged
});
}
continue;
}
if (line.StartsWith("1 ", StringComparison.Ordinal))
{
modified++;
AddOrdinary(changes, FieldAt(line, 1), ParsePath(RemainderAfter(line, 8)), null);
continue;
}
if (line.StartsWith("2 ", StringComparison.Ordinal))
{
modified++;
var (path, original) = ParseRename(RemainderAfter(line, 9));
AddOrdinary(changes, FieldAt(line, 1), path, original);
}
}
@@ -85,6 +125,7 @@ public static class GitPorcelainParser
branch = "HEAD";
}
changes.Sort(CompareChanges);
return new GitStatus
{
RepoRoot = repoRoot,
@@ -92,7 +133,188 @@ public static class GitPorcelainParser
ModifiedCount = modified,
UntrackedCount = untracked,
Ahead = ahead,
Behind = behind
Behind = behind,
Changes = changes
};
}
private static void AddOrdinary(List<GitChange> changes, string xy, string? path, string? original)
{
if (path is null)
{
return;
}
var index = CodeAt(xy, 0);
var work = CodeAt(xy, 1);
if (index is not '.' and not ' ')
{
changes.Add(new GitChange
{
Path = path,
OriginalPath = original,
Index = index,
WorkTree = work,
State = GitChangeState.Staged
});
}
if (work is not '.' and not ' ')
{
changes.Add(new GitChange
{
Path = path,
OriginalPath = original,
Index = index,
WorkTree = work,
State = GitChangeState.Unstaged
});
}
}
private static int CompareChanges(GitChange left, GitChange right)
{
var byState = StateOrder(left.State).CompareTo(StateOrder(right.State));
if (byState != 0)
{
return byState;
}
return string.Compare(left.DisplayPath, right.DisplayPath, StringComparison.OrdinalIgnoreCase);
}
private static int StateOrder(GitChangeState state) => state switch
{
GitChangeState.Unmerged => 0,
GitChangeState.Staged => 1,
GitChangeState.Unstaged => 2,
GitChangeState.Untracked => 3,
_ => 4
};
private static char CodeAt(string xy, int index)
=> xy.Length > index ? xy[index] : '.';
private static string FieldAt(string line, int index)
{
var i = 0;
for (var n = 0; n <= index; n++)
{
while (i < line.Length && line[i] == ' ')
{
i++;
}
if (i >= line.Length)
{
return "";
}
var start = i;
while (i < line.Length && line[i] != ' ')
{
i++;
}
if (n == index)
{
return line[start..i];
}
}
return "";
}
private static string? RemainderAfter(string line, int skipTokens)
{
var i = 0;
for (var n = 0; n < skipTokens; n++)
{
while (i < line.Length && line[i] == ' ')
{
i++;
}
if (i >= line.Length)
{
return null;
}
while (i < line.Length && line[i] != ' ')
{
i++;
}
}
while (i < line.Length && line[i] == ' ')
{
i++;
}
return i >= line.Length ? null : line[i..];
}
private static (string? Path, string? Original) ParseRename(string? remainder)
{
if (string.IsNullOrEmpty(remainder))
{
return (null, null);
}
var tab = remainder.IndexOf('\t');
if (tab < 0)
{
return (ParsePath(remainder), null);
}
return (ParsePath(remainder[..tab]), ParsePath(remainder[(tab + 1)..]));
}
private static string? ParsePath(string? raw)
{
if (string.IsNullOrEmpty(raw))
{
return null;
}
var path = Unquote(raw.TrimEnd());
return string.IsNullOrEmpty(path) ? null : path;
}
internal static string Unquote(string path)
{
if (path.Length < 2 || path[0] != '"')
{
return path;
}
var end = path.Length - 1;
if (path[end] != '"')
{
return path;
}
var text = new StringBuilder(path.Length - 2);
for (var i = 1; i < end; i++)
{
if (path[i] != '\\' || i + 1 >= end)
{
text.Append(path[i]);
continue;
}
i++;
text.Append(path[i] switch
{
'n' => '\n',
't' => '\t',
'r' => '\r',
'"' => '"',
'\\' => '\\',
_ => path[i]
});
}
return text.ToString();
}
}

View File

@@ -50,4 +50,94 @@ public static class GitRepoDetector
return null;
}
public static string? FindGitDir(
string repoRoot,
Func<string, bool>? directoryExists = null,
Func<string, bool>? fileExists = null,
Func<string, string>? readText = null)
{
if (string.IsNullOrWhiteSpace(repoRoot) || LocationRoots.IsVirtual(repoRoot))
{
return null;
}
directoryExists ??= Directory.Exists;
fileExists ??= File.Exists;
var git = Path.Combine(PathRules.FromExtended(repoRoot), ".git");
if (directoryExists(git))
{
return git;
}
if (!fileExists(git))
{
return null;
}
try
{
readText ??= File.ReadAllText;
var text = readText(git).Trim();
const string prefix = "gitdir:";
if (!text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var spec = text[prefix.Length..].Trim().Replace('/', '\\');
return Path.IsPathRooted(spec)
? spec
: Path.GetFullPath(Path.Combine(PathRules.FromExtended(repoRoot), spec));
}
catch
{
return null;
}
}
public static GitOperationKind GetOperation(
string repoRoot,
Func<string, bool>? directoryExists = null,
Func<string, bool>? fileExists = null,
Func<string, string>? readText = null)
{
directoryExists ??= Directory.Exists;
fileExists ??= File.Exists;
var gitDir = FindGitDir(repoRoot, directoryExists, fileExists, readText);
if (gitDir is null)
{
return GitOperationKind.None;
}
if (fileExists(Path.Combine(gitDir, "MERGE_HEAD")))
{
return GitOperationKind.Merge;
}
if (directoryExists(Path.Combine(gitDir, "rebase-merge"))
|| directoryExists(Path.Combine(gitDir, "rebase-apply")))
{
return GitOperationKind.Rebase;
}
if (fileExists(Path.Combine(gitDir, "CHERRY_PICK_HEAD")))
{
return GitOperationKind.CherryPick;
}
if (fileExists(Path.Combine(gitDir, "REVERT_HEAD")))
{
return GitOperationKind.Revert;
}
return GitOperationKind.None;
}
public static bool IsOperationInProgress(
string repoRoot,
Func<string, bool>? directoryExists = null,
Func<string, bool>? fileExists = null,
Func<string, string>? readText = null)
=> GetOperation(repoRoot, directoryExists, fileExists, readText) != GitOperationKind.None;
}

View File

@@ -0,0 +1,30 @@
using Explorer.Domain;
namespace Explorer.Application;
public interface IGitCommandProvider
{
bool IsAvailable { get; }
Task<GitStatus?> StatusAsync(string path, CancellationToken cancellationToken = default);
Task<GitDiff> DiffAsync(string path, GitChange change, CancellationToken cancellationToken = default);
Task<GitCommandResult> CommitAsync(
string path,
string message,
IReadOnlyList<string> relativePaths,
CancellationToken cancellationToken = default);
Task<GitCommandResult> StageAsync(string path, string relativePath, CancellationToken cancellationToken = default);
Task<GitCommandResult> UnstageAsync(string path, string relativePath, CancellationToken cancellationToken = default);
Task<GitCommandResult> DiscardAsync(string path, GitChange change, CancellationToken cancellationToken = default);
Task<GitCommandResult> CheckoutConflictAsync(
string path,
string relativePath,
GitConflictSide side,
CancellationToken cancellationToken = default);
Task<GitCommandResult> AbortOperationAsync(string path, CancellationToken cancellationToken = default);
Task<GitCommandResult> ContinueOperationAsync(string path, CancellationToken cancellationToken = default);
Task<GitCommandResult> FetchAsync(string path, CancellationToken cancellationToken = default);
Task<GitCommandResult> PullAsync(string path, CancellationToken cancellationToken = default);
Task<GitCommandResult> PullMergeAsync(string path, CancellationToken cancellationToken = default);
Task<GitCommandResult> PushAsync(string path, CancellationToken cancellationToken = default);
void Invalidate(string? repoRoot = null);
}

View File

@@ -13,5 +13,5 @@ public interface IGitStatusProvider
public interface IWorkspaceLauncher
{
void OpenTerminal(string directory);
bool TryOpenInCursor(string directory);
bool TryOpenInCursor(string path);
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
@@ -12,6 +13,11 @@ public sealed class SourceManager
private readonly IClock _clock;
private readonly ILogger<SourceManager> _logger;
private readonly object _refreshLock = new();
private Task<IReadOnlyList<Source>>? _refreshInFlight;
private long _refreshCacheTimestamp;
private static readonly TimeSpan RefreshCacheTtl = TimeSpan.FromSeconds(2);
public SourceManager(
IIndexStore store,
IVolumeService volumes,
@@ -34,8 +40,37 @@ public sealed class SourceManager
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<Source>> RefreshOnlineStateAsync(CancellationToken cancellationToken = default)
public Task<IReadOnlyList<Source>> RefreshOnlineStateAsync(CancellationToken cancellationToken = default)
=> RefreshOnlineStateAsync(forceRefresh: false, cancellationToken);
public Task<IReadOnlyList<Source>> RefreshOnlineStateAsync(bool forceRefresh, CancellationToken cancellationToken = default)
{
lock (_refreshLock)
{
if (!forceRefresh && _refreshInFlight is { IsCompleted: false })
{
return _refreshInFlight;
}
if (!forceRefresh
&& BoundedWait.IsFresh(_refreshCacheTimestamp, RefreshCacheTtl))
{
return _store.Sources.GetAllAsync(cancellationToken);
}
if (forceRefresh && _refreshInFlight is { IsCompleted: false })
{
return _refreshInFlight;
}
_refreshInFlight = RefreshOnlineStateCoreAsync(cancellationToken);
return _refreshInFlight;
}
}
private async Task<IReadOnlyList<Source>> RefreshOnlineStateCoreAsync(CancellationToken cancellationToken)
{
var started = Stopwatch.GetTimestamp();
var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
var online = _volumes.EnumerateOnlineVolumes();
var seenIds = new HashSet<long>();
@@ -47,6 +82,7 @@ public sealed class SourceManager
if (match.Source is not null && !match.Ambiguous)
{
source = match.Source;
var wasOffline = source.Status == SourceStatus.Offline;
source.LastRootPath = fp.RootPath;
source.DisplayName = fp.DisplayName ?? source.DisplayName;
source.Label = fp.Label ?? source.Label;
@@ -58,10 +94,16 @@ public sealed class SourceManager
source.LastSeenUtc = _clock.UtcNow;
source.Status = await ResolveReachableStatusAsync(source, cancellationToken).ConfigureAwait(false);
source.LastError = null;
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
if (source.IsIndexed)
await TryIndexWrite(
() => _store.Sources.UpsertAsync(source, cancellationToken),
"source upsert",
source.Id).ConfigureAwait(false);
if (source.IsIndexed && wasOffline)
{
await _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken).ConfigureAwait(false);
await TryIndexWrite(
() => _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken),
"mark online",
source.Id).ConfigureAwait(false);
}
}
else if (fp.Kind.IsNetwork())
@@ -99,17 +141,24 @@ public sealed class SourceManager
continue;
}
var reachable = source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath);
if (reachable)
if (source.LastRootPath is not null
&& (source.Kind.IsNetwork() || PathRules.IsUnc(source.LastRootPath))
&& _volumes.IsPathReachable(source.LastRootPath))
{
continue;
}
if (source.Status != SourceStatus.Offline)
{
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Offline, null, cancellationToken)
.ConfigureAwait(false);
await _store.Entries.MarkSourceOfflineAsync(source.Id, cancellationToken).ConfigureAwait(false);
await TryIndexWrite(
() => _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Offline, null, cancellationToken),
"mark source offline",
source.Id).ConfigureAwait(false);
await TryIndexWrite(
() => _store.Entries.MarkSourceOfflineAsync(source.Id, cancellationToken),
"mark entries offline",
source.Id).ConfigureAwait(false);
source.Status = SourceStatus.Offline;
}
}
@@ -125,7 +174,29 @@ public sealed class SourceManager
await AddUncAsync(unc, cancellationToken).ConfigureAwait(false);
}
return await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var result = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
lock (_refreshLock)
{
_refreshCacheTimestamp = Stopwatch.GetTimestamp();
}
_logger.LogInformation(
"Refreshed {Count} sources in {Ms} ms",
result.Count,
(long)Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return result;
}
private async Task TryIndexWrite(Func<Task> work, string what, long id)
{
try
{
await work().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Skipped {What} for source {Id}; index may be busy", what, id);
}
}
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
@@ -148,6 +219,7 @@ public sealed class SourceManager
existing.Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline;
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
RememberUnc(root);
InvalidateRefreshCache();
return existing;
}
@@ -163,6 +235,7 @@ public sealed class SourceManager
};
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
RememberUnc(root);
InvalidateRefreshCache();
return source;
}
@@ -210,6 +283,7 @@ public sealed class SourceManager
}
_logger.LogInformation("Forgot disconnected source {DisplayName} ({Path})", source.DisplayName, source.LastRootPath);
InvalidateRefreshCache();
return true;
}
@@ -258,6 +332,7 @@ public sealed class SourceManager
? await ResolveReachableStatusAsync(existing, cancellationToken).ConfigureAwait(false)
: SourceStatus.Offline;
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return existing;
}
@@ -280,6 +355,7 @@ public sealed class SourceManager
source.LastSeenUtc = _clock.UtcNow;
source.Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline;
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return source;
}
@@ -303,6 +379,7 @@ public sealed class SourceManager
LastSeenUtc = _clock.UtcNow
};
created.Id = await _store.Sources.UpsertAsync(created, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return created;
}
@@ -373,6 +450,14 @@ public sealed class SourceManager
}
}
private void InvalidateRefreshCache()
{
lock (_refreshLock)
{
_refreshCacheTimestamp = 0;
}
}
public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
=> _store.Sources.GetAsync(id, cancellationToken);

View File

@@ -0,0 +1,37 @@
using System.Diagnostics;
namespace Explorer.Domain;
public static class BoundedWait
{
public static readonly TimeSpan RemoteIo = TimeSpan.FromMilliseconds(800);
public static bool Try(Func<bool> work, TimeSpan timeout)
{
try
{
var task = Task.Run(work);
return task.Wait(timeout) && task.Result;
}
catch
{
return false;
}
}
public static T Try<T>(Func<T> work, TimeSpan timeout, T fallback)
{
try
{
var task = Task.Run(work);
return task.Wait(timeout) ? task.Result : fallback;
}
catch
{
return fallback;
}
}
public static bool IsFresh(long timestamp, TimeSpan ttl)
=> timestamp != 0 && Stopwatch.GetElapsedTime(timestamp) < ttl;
}

View File

@@ -0,0 +1,43 @@
namespace Explorer.Domain;
public sealed record GitCommandResult
{
public required bool Succeeded { get; init; }
public int ExitCode { get; init; }
public string StandardOutput { get; init; } = "";
public string StandardError { get; init; } = "";
public string Summary { get; init; } = "";
public bool SuggestTerminal { get; init; }
public bool SuggestMergePull { get; init; }
public string DisplayMessage
{
get
{
if (!string.IsNullOrWhiteSpace(Summary))
{
return Summary;
}
var text = Succeeded ? StandardOutput : StandardError;
if (string.IsNullOrWhiteSpace(text))
{
text = Succeeded ? StandardError : StandardOutput;
}
text = text.Trim();
if (text.Length == 0)
{
return Succeeded ? "Done." : $"git failed (exit {ExitCode}).";
}
return text.Length <= 2000 ? text : text[..2000] + "…";
}
}
public static GitCommandResult Fail(string summary, bool suggestTerminal = false)
=> new() { Succeeded = false, Summary = summary, SuggestTerminal = suggestTerminal };
public static GitCommandResult Ok(string summary)
=> new() { Succeeded = true, Summary = summary };
}

View File

@@ -1,5 +1,112 @@
namespace Explorer.Domain;
public enum GitChangeState
{
Staged,
Unstaged,
Untracked,
Unmerged
}
public enum GitOperationKind
{
None,
Merge,
Rebase,
CherryPick,
Revert
}
public enum GitConflictSide
{
Ours,
Theirs
}
public enum GitDiffLineKind
{
Meta,
Hunk,
Context,
Added,
Removed
}
public sealed record GitDiffLine(GitDiffLineKind Kind, string Text);
public sealed record GitDiff
{
public required string Title { get; init; }
public IReadOnlyList<GitDiffLine> Lines { get; init; } = [];
public bool IsBinary { get; init; }
public string? Error { get; init; }
public bool IsEmpty => !IsBinary && Error is null && Lines.Count == 0;
public static GitDiff Fail(string title, string error)
=> new() { Title = title, Error = error };
}
public sealed record GitChange
{
public required string Path { get; init; }
public string? OriginalPath { get; init; }
public char Index { get; init; }
public char WorkTree { get; init; }
public required GitChangeState State { get; init; }
public string KindLabel => State switch
{
GitChangeState.Staged => "Staged",
GitChangeState.Unstaged => "Unstaged",
GitChangeState.Untracked => "Untracked",
GitChangeState.Unmerged => "Unmerged",
_ => State.ToString()
};
public string ChangeLabel
{
get
{
if (State == GitChangeState.Untracked)
{
return "untracked";
}
if (State == GitChangeState.Unmerged)
{
return "unmerged";
}
var code = State == GitChangeState.Staged ? Index : WorkTree;
return Describe(code);
}
}
public string DisplayPath
=> string.IsNullOrEmpty(OriginalPath) ? Path : OriginalPath + " → " + Path;
public bool IsDeleted
=> (State == GitChangeState.Staged && Index == 'D')
|| (State == GitChangeState.Unstaged && WorkTree == 'D');
public string ToFullPath(string repoRoot) => PathRules.Combine(repoRoot, Path);
public static string Describe(char code) => code switch
{
'M' => "modified",
'A' => "added",
'D' => "deleted",
'R' => "renamed",
'C' => "copied",
'T' => "type change",
'U' => "unmerged",
'?' => "untracked",
'.' or ' ' => "",
_ => char.ToString(code)
};
}
public sealed record GitStatus
{
public required string RepoRoot { get; init; }
@@ -8,14 +115,31 @@ public sealed record GitStatus
public int UntrackedCount { get; init; }
public int Ahead { get; init; }
public int Behind { get; init; }
public GitOperationKind Operation { get; init; }
public IReadOnlyList<GitChange> Changes { get; init; } = [];
public bool WorkingTreeClean => ModifiedCount == 0 && UntrackedCount == 0;
public bool HasUnmerged => Changes.Any(c => c.State == GitChangeState.Unmerged);
public string OperationLabel => Operation switch
{
GitOperationKind.Merge => "merging",
GitOperationKind.Rebase => "rebasing",
GitOperationKind.CherryPick => "cherry-picking",
GitOperationKind.Revert => "reverting",
_ => ""
};
public string Badge
{
get
{
var parts = new List<string> { Branch };
if (!string.IsNullOrEmpty(OperationLabel))
{
parts.Add(OperationLabel);
}
if (ModifiedCount > 0)
{
parts.Add($"{ModifiedCount} modified");
@@ -36,7 +160,7 @@ public sealed record GitStatus
parts.Add($"{Behind} behind");
}
if (WorkingTreeClean && Ahead == 0 && Behind == 0)
if (WorkingTreeClean && Ahead == 0 && Behind == 0 && Operation == GitOperationKind.None)
{
parts.Add("clean");
}

View File

@@ -294,6 +294,12 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
ApplyCurrentSort();
}
public Task RefreshGitAsync()
{
var ct = _loadCts?.Token ?? CancellationToken.None;
return ApplyGitAsync(CurrentPath, ct);
}
private async Task ApplyGitAsync(string path, CancellationToken cancellationToken)
{
GitBadge = "";

View File

@@ -0,0 +1,367 @@
using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Presentation.ViewModels;
public sealed partial class GitChangesViewModel : ObservableObject
{
private readonly IGitCommandProvider _git;
private readonly IWorkspaceLauncher _workspace;
private readonly IHydrationGuard _hydration;
private CancellationTokenSource? _loadCts;
private string? _path;
[ObservableProperty] private string _repoRoot = "";
[ObservableProperty] private string _branch = "";
[ObservableProperty] private string _summary = "Open a Git repository to see changes.";
[ObservableProperty] private string _status = "";
[ObservableProperty] private GitChange? _selected;
[ObservableProperty] private GitOperationKind _operation;
[ObservableProperty] private bool _canCommit;
[ObservableProperty] private bool _canNetwork;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _canOpenInCursor;
[ObservableProperty] private bool _canDiff;
[ObservableProperty] private bool _canStage;
[ObservableProperty] private bool _canUnstage;
[ObservableProperty] private bool _canDiscard;
[ObservableProperty] private bool _canResolve;
[ObservableProperty] private bool _canAbort;
[ObservableProperty] private bool _canContinue;
public GitChangesViewModel(IGitCommandProvider git, IWorkspaceLauncher workspace, IHydrationGuard hydration)
{
_git = git;
_workspace = workspace;
_hydration = hydration;
Changes = [];
}
public ObservableCollection<GitChange> Changes { get; }
public bool HasOperation => Operation != GitOperationKind.None;
public async Task LoadAsync(string? path, CancellationToken cancellationToken = default)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var ct = _loadCts.Token;
_path = path;
IsBusy = true;
Status = "";
try
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
ShowEmpty("Not a Git repository.");
return;
}
if (!_git.IsAvailable)
{
ShowEmpty("git.exe was not found. Set the path in Settings.");
return;
}
var status = await _git.StatusAsync(path, ct).ConfigureAwait(true);
if (ct.IsCancellationRequested)
{
return;
}
if (status is null)
{
ShowEmpty("Not a Git repository.");
return;
}
RepoRoot = status.RepoRoot;
Branch = status.Branch;
Operation = status.Operation;
Summary = status.Badge;
Changes.Clear();
foreach (var change in status.Changes)
{
Changes.Add(change);
}
Status = status.Operation != GitOperationKind.None
? status.HasUnmerged
? $"{status.OperationLabel}: resolve conflicts, then Continue."
: $"{status.OperationLabel}: Continue to finish, or Abort."
: status.WorkingTreeClean
? "Working tree is clean."
: $"{status.Changes.Count} change{(status.Changes.Count == 1 ? "" : "s")}.";
CanCommit = status.Operation == GitOperationKind.None
&& !status.WorkingTreeClean
&& status.Changes.Any(c => c.State != GitChangeState.Unmerged);
CanNetwork = true;
RefreshSelectedActions();
}
catch (OperationCanceledException)
{
// superseded
}
catch (Exception ex)
{
if (!ct.IsCancellationRequested)
{
ShowEmpty(ex.Message);
}
}
finally
{
if (!ct.IsCancellationRequested)
{
IsBusy = false;
}
}
}
[RelayCommand]
public Task RefreshAsync() => LoadAsync(_path);
[RelayCommand]
public async Task OpenSelectedInCursorAsync()
{
if (Selected is null || string.IsNullOrWhiteSpace(RepoRoot))
{
return;
}
var full = Selected.ToFullPath(RepoRoot);
if (Selected.IsDeleted)
{
Status = "This path is deleted in the working tree.";
return;
}
if (await _hydration.WouldHydrateOnReadAsync(full).ConfigureAwait(true))
{
Status = "This file is online-only. Opening it would download it.";
return;
}
if (!File.Exists(full) && !Directory.Exists(full))
{
Status = "This path is not on disk.";
return;
}
if (!_workspace.TryOpenInCursor(full))
{
Status = "Cursor is not installed.";
}
}
public GitCommitViewModel? CreateCommitViewModel()
{
if (string.IsNullOrWhiteSpace(RepoRoot) || Changes.Count == 0 || Operation != GitOperationKind.None)
{
return null;
}
return new GitCommitViewModel(_git, _hydration, RepoRoot, Changes.ToList());
}
public async Task<GitDiff?> DiffSelectedAsync()
{
if (Selected is null || string.IsNullOrWhiteSpace(RepoRoot))
{
Status = "Select a file to diff.";
return null;
}
var request = GitDiffPlanner.Create(Selected);
if (request.Error is not null)
{
Status = request.Error;
return GitDiff.Fail(Selected.DisplayPath, request.Error);
}
if (request.NeedsWorkingTree)
{
var full = Selected.ToFullPath(RepoRoot);
if (Directory.Exists(full))
{
Status = "Folders have no file diff.";
return GitDiff.Fail(Selected.DisplayPath, Status);
}
if (await _hydration.WouldHydrateOnReadAsync(full).ConfigureAwait(true))
{
Status = "This file is online-only. Diffing it would download it.";
return GitDiff.Fail(Selected.DisplayPath, Status);
}
}
IsBusy = true;
try
{
var diff = await _git.DiffAsync(RepoRoot, Selected).ConfigureAwait(true);
Status = diff.Error ?? (diff.IsBinary ? "Binary file." : diff.IsEmpty ? "No textual difference." : "Diff.");
return diff;
}
catch (Exception ex)
{
Status = ex.Message;
return GitDiff.Fail(Selected.DisplayPath, ex.Message);
}
finally
{
IsBusy = false;
}
}
public Task<GitCommandResult> FetchAsync() => RunGitAsync("Fetching…", ct => _git.FetchAsync(RepoRoot, ct));
public Task<GitCommandResult> PullAsync() => RunGitAsync("Pulling…", ct => _git.PullAsync(RepoRoot, ct));
public Task<GitCommandResult> PullMergeAsync() => RunGitAsync("Pulling (merge)…", ct => _git.PullMergeAsync(RepoRoot, ct));
public Task<GitCommandResult> PushAsync() => RunGitAsync("Pushing…", ct => _git.PushAsync(RepoRoot, ct));
public async Task<GitCommandResult> StageSelectedAsync()
{
if (Selected is null)
{
return GitCommandResult.Fail("Select a file to stage.");
}
if (await WouldHydrateSelectedAsync().ConfigureAwait(true))
{
return GitCommandResult.Fail("This file is online-only. Staging it would download it.");
}
return await RunGitAsync("Staging…", ct => _git.StageAsync(RepoRoot, Selected.Path, ct)).ConfigureAwait(true);
}
public Task<GitCommandResult> UnstageSelectedAsync()
=> Selected is null
? Task.FromResult(GitCommandResult.Fail("Select a file to unstage."))
: RunGitAsync("Unstaging…", ct => _git.UnstageAsync(RepoRoot, Selected.Path, ct));
public async Task<GitCommandResult> DiscardSelectedAsync()
{
if (Selected is null)
{
return GitCommandResult.Fail("Select a file to discard.");
}
return await RunGitAsync("Discarding…", ct => _git.DiscardAsync(RepoRoot, Selected, ct)).ConfigureAwait(true);
}
public Task<GitCommandResult> UseOursAsync() => CheckoutConflictAsync(GitConflictSide.Ours);
public Task<GitCommandResult> UseTheirsAsync() => CheckoutConflictAsync(GitConflictSide.Theirs);
public Task<GitCommandResult> MarkResolvedAsync()
=> Selected is null
? Task.FromResult(GitCommandResult.Fail("Select a conflicted file."))
: RunGitAsync("Marking resolved…", ct => _git.StageAsync(RepoRoot, Selected.Path, ct));
public Task<GitCommandResult> AbortAsync()
=> RunGitAsync("Aborting…", ct => _git.AbortOperationAsync(RepoRoot, ct));
public Task<GitCommandResult> ContinueAsync()
=> RunGitAsync("Continuing…", ct => _git.ContinueOperationAsync(RepoRoot, ct));
private async Task<GitCommandResult> CheckoutConflictAsync(GitConflictSide side)
{
if (Selected is null)
{
return GitCommandResult.Fail("Select a conflicted file.");
}
var label = side == GitConflictSide.Ours ? "ours" : "theirs";
return await RunGitAsync(
$"Keeping {label}…",
ct => _git.CheckoutConflictAsync(RepoRoot, Selected.Path, side, ct)).ConfigureAwait(true);
}
private async Task<GitCommandResult> RunGitAsync(
string pending,
Func<CancellationToken, Task<GitCommandResult>> work)
{
if (string.IsNullOrWhiteSpace(RepoRoot))
{
return GitCommandResult.Fail("Not a Git repository.");
}
IsBusy = true;
Status = pending;
CanNetwork = false;
CanCommit = false;
try
{
var result = await work(CancellationToken.None).ConfigureAwait(true);
Status = result.DisplayMessage;
await LoadAsync(_path).ConfigureAwait(true);
return result;
}
catch (Exception ex)
{
var fail = GitCommandResult.Fail(ex.Message, suggestTerminal: true);
Status = fail.DisplayMessage;
CanNetwork = !string.IsNullOrWhiteSpace(RepoRoot);
RefreshSelectedActions();
return fail;
}
finally
{
IsBusy = false;
}
}
private async Task<bool> WouldHydrateSelectedAsync()
{
if (Selected is null || Selected.IsDeleted)
{
return false;
}
return await _hydration.WouldHydrateOnReadAsync(Selected.ToFullPath(RepoRoot)).ConfigureAwait(true);
}
partial void OnSelectedChanged(GitChange? value) => RefreshSelectedActions();
partial void OnOperationChanged(GitOperationKind value) => OnPropertyChanged(nameof(HasOperation));
private void RefreshSelectedActions()
{
var selected = Selected;
CanOpenInCursor = selected is { IsDeleted: false };
CanDiff = selected is not null;
CanStage = selected is { State: GitChangeState.Unstaged or GitChangeState.Untracked };
CanUnstage = selected is { State: GitChangeState.Staged };
CanDiscard = selected is { State: GitChangeState.Unstaged or GitChangeState.Untracked or GitChangeState.Staged };
CanResolve = selected is { State: GitChangeState.Unmerged };
CanAbort = Operation != GitOperationKind.None;
CanContinue = Operation != GitOperationKind.None && Changes.All(c => c.State != GitChangeState.Unmerged);
}
private void ShowEmpty(string message)
{
RepoRoot = "";
Branch = "";
Operation = GitOperationKind.None;
Summary = message;
Status = message;
Changes.Clear();
Selected = null;
CanOpenInCursor = false;
CanCommit = false;
CanNetwork = false;
CanDiff = false;
CanStage = false;
CanUnstage = false;
CanDiscard = false;
CanResolve = false;
CanAbort = false;
CanContinue = false;
}
}

View File

@@ -0,0 +1,151 @@
using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Presentation.ViewModels;
public sealed partial class GitCommitItem : ObservableObject
{
[ObservableProperty] private bool _include;
public GitCommitItem(GitChange change, bool include, bool canInclude)
{
Change = change;
CanInclude = canInclude;
Include = include && canInclude;
}
public GitChange Change { get; }
public bool CanInclude { get; }
public string KindLabel => Change.KindLabel;
public string ChangeLabel => Change.ChangeLabel;
public string DisplayPath => Change.DisplayPath;
}
public sealed partial class GitCommitViewModel : ObservableObject
{
private readonly IGitCommandProvider _git;
private readonly IHydrationGuard _hydration;
private readonly string _repoRoot;
[ObservableProperty] private string _message = "";
[ObservableProperty] private string _status = "Write a message and choose files.";
[ObservableProperty] private bool _canCommit;
[ObservableProperty] private bool _isBusy;
public GitCommitViewModel(
IGitCommandProvider git,
IHydrationGuard hydration,
string repoRoot,
IReadOnlyList<GitChange> changes)
{
_git = git;
_hydration = hydration;
_repoRoot = repoRoot;
RepoRoot = repoRoot;
Files = [];
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var change in changes)
{
if (!seen.Add(change.Path))
{
continue;
}
var can = change.State != GitChangeState.Unmerged;
Files.Add(new GitCommitItem(change, include: can, canInclude: can));
}
foreach (var row in Files)
{
row.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(GitCommitItem.Include))
{
RefreshCanCommit();
}
};
}
RefreshCanCommit();
var skipped = Files.Count(f => !f.CanInclude);
if (skipped > 0)
{
Status = $"{skipped} unmerged path(s) cannot be committed here.";
}
}
public string RepoRoot { get; }
public ObservableCollection<GitCommitItem> Files { get; }
public event EventHandler? CloseRequested;
partial void OnMessageChanged(string value) => RefreshCanCommit();
partial void OnIsBusyChanged(bool value) => RefreshCanCommit();
[RelayCommand]
public async Task CommitAsync()
{
RefreshCanCommit();
if (!CanCommit)
{
return;
}
IsBusy = true;
try
{
var included = Files.Where(f => f.Include).Select(f => f.Change).ToList();
var hydrate = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var change in included)
{
var full = change.ToFullPath(_repoRoot);
if (!change.IsDeleted && Directory.Exists(full))
{
directories.Add(change.Path);
}
if (!change.IsDeleted && await _hydration.WouldHydrateOnReadAsync(full).ConfigureAwait(true))
{
hydrate.Add(change.Path);
}
}
var plan = GitCommitPlanner.Create(
Message,
included,
hydrate,
directories,
GitRepoDetector.IsOperationInProgress(_repoRoot));
if (!plan.CanCommit)
{
Status = plan.Error ?? "Nothing to commit.";
return;
}
var result = await _git.CommitAsync(_repoRoot, Message.Trim(), plan.Paths).ConfigureAwait(true);
Status = result.DisplayMessage;
if (result.Succeeded)
{
CloseRequested?.Invoke(this, EventArgs.Empty);
}
}
catch (Exception ex)
{
Status = ex.Message;
}
finally
{
IsBusy = false;
}
}
private void RefreshCanCommit()
=> CanCommit = !IsBusy
&& !string.IsNullOrWhiteSpace(Message)
&& Files.Any(f => f.Include);
}

View File

@@ -0,0 +1,21 @@
using Explorer.Domain;
namespace Explorer.Presentation.ViewModels;
public sealed class GitDiffViewModel
{
public GitDiffViewModel(GitDiff diff)
{
Diff = diff;
Title = string.IsNullOrWhiteSpace(diff.Title) ? "Diff" : diff.Title;
EmptyText = diff.Error
?? (diff.IsBinary ? "Binary file — no text diff." : "No textual difference.");
ShowEmpty = diff.Error is not null || diff.IsBinary || diff.IsEmpty;
}
public GitDiff Diff { get; }
public string Title { get; }
public string EmptyText { get; }
public bool ShowEmpty { get; }
public IReadOnlyList<GitDiffLine> Lines => Diff.Lines;
}

View File

@@ -28,7 +28,9 @@ public sealed partial class MainViewModel : ObservableObject
private readonly OperationProfileService _operationProfiles;
private readonly ReorganizeService _reorganize;
private readonly IGitStatusProvider _git;
private readonly IGitCommandProvider _gitCommands;
private readonly IWorkspaceLauncher _workspace;
private readonly IHydrationGuard _hydration;
private readonly IThumbnailService? _thumbnails;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -80,6 +82,8 @@ public sealed partial class MainViewModel : ObservableObject
ReorganizeService reorganize,
IGitStatusProvider git,
IWorkspaceLauncher workspace,
IGitCommandProvider gitCommands,
IHydrationGuard hydration,
IThumbnailService? thumbnails = null)
{
_browse = browse;
@@ -96,7 +100,9 @@ public sealed partial class MainViewModel : ObservableObject
_operationProfiles = operationProfiles;
_reorganize = reorganize;
_git = git;
_gitCommands = gitCommands;
_workspace = workspace;
_hydration = hydration;
_thumbnails = thumbnails;
var prefs = preferences.Load();
Theme = prefs.Theme;
@@ -154,16 +160,36 @@ public sealed partial class MainViewModel : ObservableObject
public DuplicateViewModel Duplicates { get; }
public TransferQueueViewModel Transfers { get; }
public ExplorerPaneViewModel ActivePane => ActiveTab.ActivePane;
public event EventHandler? WorkspaceChanged;
public void PrepareUi()
{
if (Tabs.Count > 0)
{
return;
}
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails);
WireTab(tab);
Tabs.Add(tab);
ActiveTab = tab;
PathText = tab.ActivePane.CurrentPath;
tab.Left.StatusMessage = "Loading locations…";
Footer = "Starting…";
Tree.PreparePlaceholder();
}
public async Task InitializeAsync()
{
PrepareUi();
await _sources.InitializeAsync().ConfigureAwait(true);
foreach (var path in _pathHistory.Load())
{
PathHistory.Add(path);
}
await NewTabAsync().ConfigureAwait(true);
await ActiveTab.OpenInitialAsync().ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
Footer = "Ready";
}
@@ -457,6 +483,89 @@ public sealed partial class MainViewModel : ObservableObject
public ReorganizeViewModel CreateReorganizeViewModel()
=> new(_reorganize, OrganizeSourcePath());
public GitChangesViewModel CreateGitChangesViewModel()
=> new(_gitCommands, _workspace, _hydration);
public async Task<GitCommitViewModel?> CreateGitCommitViewModelAsync()
{
var path = GitWorkspacePath();
if (path is null)
{
Footer = "Select a folder in a Git repository.";
return null;
}
if (!_gitCommands.IsAvailable)
{
Footer = "git.exe was not found. Set the path in Settings.";
return null;
}
var status = await _gitCommands.StatusAsync(path).ConfigureAwait(true);
if (status is null)
{
Footer = "Not a Git repository.";
return null;
}
if (status.WorkingTreeClean)
{
Footer = "Working tree is clean.";
return null;
}
return new GitCommitViewModel(_gitCommands, _hydration, status.RepoRoot, status.Changes);
}
public Task<GitCommandResult> GitFetchAsync() => RunGitNetworkAsync("Fetching…", _gitCommands.FetchAsync);
public Task<GitCommandResult> GitPullAsync() => RunGitNetworkAsync("Pulling…", _gitCommands.PullAsync);
public Task<GitCommandResult> GitPullMergeAsync() => RunGitNetworkAsync("Pulling (merge)…", _gitCommands.PullMergeAsync);
public Task<GitCommandResult> GitPushAsync() => RunGitNetworkAsync("Pushing…", _gitCommands.PushAsync);
public async Task RefreshGitOverlaysAsync()
{
_gitCommands.Invalidate();
await ActiveTab.Left.RefreshGitAsync().ConfigureAwait(true);
if (ActiveTab.IsSplit)
{
await ActiveTab.Right.RefreshGitAsync().ConfigureAwait(true);
}
}
private async Task<GitCommandResult> RunGitNetworkAsync(
string pending,
Func<string, CancellationToken, Task<GitCommandResult>> work)
{
var path = GitWorkspacePath();
if (path is null)
{
var fail = GitCommandResult.Fail("Select a folder in a Git repository.");
Footer = fail.DisplayMessage;
return fail;
}
Footer = pending;
var result = await work(path, CancellationToken.None).ConfigureAwait(true);
Footer = result.DisplayMessage;
await RefreshGitOverlaysAsync().ConfigureAwait(true);
return result;
}
public string? GitWorkspacePath()
{
var directory = WorkspaceDirectory();
if (directory is not null)
{
return directory;
}
var path = ActivePane.CurrentPath;
return LocationRoots.IsVirtual(path) ? null : path;
}
public string? OrganizeSourcePath() => WorkspaceDirectory();
public Task<IReadOnlyList<OperationProfile>> ListOperationProfilesAsync()
@@ -1015,6 +1124,7 @@ public sealed partial class MainViewModel : ObservableObject
{
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
}
};
}
@@ -1031,6 +1141,7 @@ public sealed partial class MainViewModel : ObservableObject
PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
RefreshCloudActions();
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
}
else if (propertyName is nameof(ExplorerPaneViewModel.GitBadge)
@@ -1208,5 +1319,6 @@ public sealed partial class MainViewModel : ObservableObject
{
PathText = value.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane));
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -53,6 +53,24 @@ public sealed class NavigationTreeViewModel
public bool IsRevealing { get; private set; }
public void PreparePlaceholder()
{
if (Roots.Count > 0)
{
return;
}
Roots.Add(new NavNodeViewModel
{
Label = LocationRoots.ThisPc,
Path = LocationRoots.ThisPc,
Glyph = "\uE977",
IsExpanded = true,
ChildrenLoaded = true,
IsGroup = true
});
}
public async Task ReloadAsync(string? revealPath = null, CancellationToken cancellationToken = default)
{
var expanded = new List<string>();

View File

@@ -6,10 +6,13 @@ using Explorer.Domain;
namespace Explorer.Windows;
public sealed class WindowsGitStatusProvider : IGitStatusProvider
public sealed class WindowsGitStatusProvider : IGitStatusProvider, IGitCommandProvider
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(4);
private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(4);
private static readonly TimeSpan CommitTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(60);
private static readonly TimeSpan DiffTimeout = TimeSpan.FromSeconds(15);
private readonly UiPreferencesStore _preferences;
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase);
@@ -23,9 +26,69 @@ public sealed class WindowsGitStatusProvider : IGitStatusProvider
public bool IsRepoRoot(string path) => GitRepoDetector.IsRepoRoot(path);
public Task<GitStatus?> GetStatusAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => GetStatus(path, cancellationToken), cancellationToken);
=> Task.Run(() => GetStatus(path, forceRefresh: false, cancellationToken), cancellationToken);
private GitStatus? GetStatus(string path, CancellationToken cancellationToken)
public Task<GitStatus?> StatusAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => GetStatus(path, forceRefresh: true, cancellationToken), cancellationToken);
public Task<GitDiff> DiffAsync(string path, GitChange change, CancellationToken cancellationToken = default)
=> Task.Run(() => Diff(path, change, cancellationToken), cancellationToken);
public Task<GitCommandResult> CommitAsync(
string path,
string message,
IReadOnlyList<string> relativePaths,
CancellationToken cancellationToken = default)
=> Task.Run(() => Commit(path, message, relativePaths, cancellationToken), cancellationToken);
public Task<GitCommandResult> StageAsync(string path, string relativePath, CancellationToken cancellationToken = default)
=> Task.Run(() => Pathspec(path, ["add", "--", relativePath], "Staged.", cancellationToken), cancellationToken);
public Task<GitCommandResult> UnstageAsync(string path, string relativePath, CancellationToken cancellationToken = default)
=> Task.Run(
() => Pathspec(path, ["restore", "--staged", "--", relativePath], "Unstaged.", cancellationToken),
cancellationToken);
public Task<GitCommandResult> DiscardAsync(string path, GitChange change, CancellationToken cancellationToken = default)
=> Task.Run(() => Discard(path, change, cancellationToken), cancellationToken);
public Task<GitCommandResult> CheckoutConflictAsync(
string path,
string relativePath,
GitConflictSide side,
CancellationToken cancellationToken = default)
=> Task.Run(() => CheckoutConflict(path, relativePath, side, cancellationToken), cancellationToken);
public Task<GitCommandResult> AbortOperationAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => FinishOperation(path, abort: true, cancellationToken), cancellationToken);
public Task<GitCommandResult> ContinueOperationAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => FinishOperation(path, abort: false, cancellationToken), cancellationToken);
public Task<GitCommandResult> FetchAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => Network(path, ["fetch"], "Fetched.", cancellationToken), cancellationToken);
public Task<GitCommandResult> PullAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => Pull(path, merge: false, cancellationToken), cancellationToken);
public Task<GitCommandResult> PullMergeAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => Pull(path, merge: true, cancellationToken), cancellationToken);
public Task<GitCommandResult> PushAsync(string path, CancellationToken cancellationToken = default)
=> Task.Run(() => Network(path, ["push"], "Pushed.", cancellationToken), cancellationToken);
public void Invalidate(string? repoRoot = null)
{
if (string.IsNullOrWhiteSpace(repoRoot))
{
_cache.Clear();
return;
}
_cache.TryRemove(repoRoot, out _);
}
private GitStatus? GetStatus(string path, bool forceRefresh, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var root = FindRepoRoot(path);
@@ -34,7 +97,7 @@ public sealed class WindowsGitStatusProvider : IGitStatusProvider
return null;
}
if (_cache.TryGetValue(root, out var hit) && DateTime.UtcNow - hit.Utc < CacheTtl)
if (!forceRefresh && _cache.TryGetValue(root, out var hit) && DateTime.UtcNow - hit.Utc < CacheTtl)
{
return hit.Status;
}
@@ -46,18 +109,300 @@ public sealed class WindowsGitStatusProvider : IGitStatusProvider
return null;
}
var status = Query(git, root, cancellationToken);
var run = Run(git, root, QueryTimeout, cancellationToken,
[
"--no-optional-locks",
"-c",
"core.quotepath=false",
"status",
"--porcelain=v2",
"-b",
"--untracked-files=normal",
"--ignore-submodules=all"
]);
var parsed = run.ExitCode == 0 ? GitPorcelainParser.Parse(run.StandardOutput, root) : null;
var status = parsed is null
? null
: parsed with { Operation = GitRepoDetector.GetOperation(root) };
_cache[root] = new CacheEntry(status, DateTime.UtcNow);
return status;
}
private static GitStatus? Query(string git, string repoRoot, CancellationToken cancellationToken)
private GitDiff Diff(string path, GitChange change, CancellationToken cancellationToken)
{
var title = change.KindLabel + " · " + change.DisplayPath;
var request = GitDiffPlanner.Create(change);
if (request.Error is not null)
{
return GitDiff.Fail(title, request.Error);
}
var ready = Prepare(path, cancellationToken);
if (ready.Result is not null)
{
return GitDiff.Fail(title, ready.Result.DisplayMessage);
}
var run = Run(ready.Git, ready.Root, DiffTimeout, cancellationToken, request.Arguments);
if (run.ExitCode is not 0 and not 1)
{
var error = string.IsNullOrWhiteSpace(run.StandardError) ? run.StandardOutput : run.StandardError;
return GitDiff.Fail(
title,
string.IsNullOrWhiteSpace(error) ? $"git diff failed (exit {run.ExitCode})." : error.Trim());
}
return GitDiffParser.Parse(run.StandardOutput, title);
}
private GitCommandResult Commit(
string path,
string message,
IReadOnlyList<string> relativePaths,
CancellationToken cancellationToken)
{
var ready = Prepare(path, cancellationToken);
if (ready.Result is not null)
{
return ready.Result;
}
var git = ready.Git;
var root = ready.Root;
if (GitRepoDetector.IsOperationInProgress(root))
{
return GitCommandResult.Fail(
"A merge or rebase is in progress. Continue or abort it first.",
suggestTerminal: true);
}
if (relativePaths.Count == 0)
{
return GitCommandResult.Fail("Select at least one file to commit.");
}
var add = new List<string> { "--no-optional-locks", "add", "--" };
add.AddRange(relativePaths);
var added = Run(git, root, CommitTimeout, cancellationToken, add);
if (added.ExitCode != 0)
{
return FailRun(added, suggestTerminal: true);
}
var file = Path.GetTempFileName();
try
{
File.WriteAllText(
file,
message.Replace("\r\n", "\n"),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var commitArgs = new List<string> { "--no-optional-locks", "commit", "--only", "-F", file, "--" };
commitArgs.AddRange(relativePaths);
var committed = Run(git, root, CommitTimeout, cancellationToken, commitArgs);
Invalidate(root);
if (committed.ExitCode != 0)
{
return FailRun(committed, suggestTerminal: true);
}
var summary = FirstLine(committed.StandardOutput);
return GitCommandResult.Ok(string.IsNullOrWhiteSpace(summary) ? "Committed." : summary);
}
finally
{
try { File.Delete(file); } catch { /* ignore */ }
}
}
private GitCommandResult Discard(string path, GitChange change, CancellationToken cancellationToken)
{
if (change.State == GitChangeState.Unmerged)
{
return GitCommandResult.Fail("Unmerged files cannot be discarded. Use ours, theirs, or abort.");
}
if (change.State == GitChangeState.Untracked)
{
return Pathspec(path, ["clean", "-f", "--", change.Path], "Discarded.", cancellationToken);
}
if (change.State == GitChangeState.Staged)
{
return Pathspec(
path,
["restore", "--source=HEAD", "--staged", "--worktree", "--", change.Path],
"Discarded.",
cancellationToken);
}
return Pathspec(path, ["restore", "--worktree", "--", change.Path], "Discarded.", cancellationToken);
}
private GitCommandResult CheckoutConflict(
string path,
string relativePath,
GitConflictSide side,
CancellationToken cancellationToken)
{
var flag = side == GitConflictSide.Ours ? "--ours" : "--theirs";
var checkout = Pathspec(
path,
["checkout", flag, "--", relativePath],
side == GitConflictSide.Ours ? "Kept ours." : "Kept theirs.",
cancellationToken);
if (!checkout.Succeeded)
{
return checkout;
}
return Pathspec(path, ["add", "--", relativePath], checkout.Summary, cancellationToken);
}
private GitCommandResult FinishOperation(string path, bool abort, CancellationToken cancellationToken)
{
var ready = Prepare(path, cancellationToken);
if (ready.Result is not null)
{
return ready.Result;
}
var operation = GitRepoDetector.GetOperation(ready.Root);
IReadOnlyList<string> command = (operation, abort) switch
{
(GitOperationKind.Merge, true) => ["merge", "--abort"],
(GitOperationKind.Merge, false) => ["commit", "--no-edit"],
(GitOperationKind.Rebase, true) => ["rebase", "--abort"],
(GitOperationKind.Rebase, false) => ["-c", "core.editor=true", "rebase", "--continue"],
(GitOperationKind.CherryPick, true) => ["cherry-pick", "--abort"],
(GitOperationKind.CherryPick, false) => ["-c", "core.editor=true", "cherry-pick", "--continue"],
(GitOperationKind.Revert, true) => ["revert", "--abort"],
(GitOperationKind.Revert, false) => ["-c", "core.editor=true", "revert", "--continue"],
_ => []
};
if (command.Count == 0)
{
return GitCommandResult.Fail("No merge or rebase is in progress.");
}
return Pathspec(
path,
command,
abort ? "Aborted." : "Continued.",
cancellationToken);
}
private GitCommandResult Pull(string path, bool merge, CancellationToken cancellationToken)
{
var command = merge
? new[] { "pull", "--no-rebase" }
: new[] { "pull", "--ff-only", "--no-rebase" };
var result = Network(path, command, "Pulled.", cancellationToken);
if (!merge && !result.Succeeded && LooksLikeFastForwardFailure(result))
{
return result with { SuggestMergePull = true, SuggestTerminal = true };
}
return result;
}
private GitCommandResult Network(
string path,
IReadOnlyList<string> command,
string ok,
CancellationToken cancellationToken)
=> Pathspec(path, command, ok, cancellationToken, NetworkTimeout);
private GitCommandResult Pathspec(
string path,
IReadOnlyList<string> command,
string ok,
CancellationToken cancellationToken,
TimeSpan? timeout = null)
{
var ready = Prepare(path, cancellationToken);
if (ready.Result is not null)
{
return ready.Result;
}
var args = new List<string> { "--no-optional-locks" };
args.AddRange(command);
var run = Run(ready.Git, ready.Root, timeout ?? CommitTimeout, cancellationToken, args);
Invalidate(ready.Root);
if (run.ExitCode != 0)
{
return FailRun(run, suggestTerminal: true);
}
var detail = FirstLine(run.StandardOutput);
if (string.IsNullOrWhiteSpace(detail))
{
detail = FirstLine(run.StandardError);
}
return GitCommandResult.Ok(string.IsNullOrWhiteSpace(detail) ? ok : detail);
}
private (string Git, string Root, GitCommandResult? Result) Prepare(string path, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var root = FindRepoRoot(path);
if (root is null)
{
return ("", "", GitCommandResult.Fail("Not a Git repository."));
}
var git = GitLocator.Find(_preferences.Load().GitPath);
if (git is null)
{
return ("", "", GitCommandResult.Fail("git.exe was not found. Set the path in Settings."));
}
return (git, root, null);
}
private static bool LooksLikeFastForwardFailure(GitCommandResult result)
{
var text = result.DisplayMessage;
return text.Contains("fast-forward", StringComparison.OrdinalIgnoreCase)
|| text.Contains("diverged", StringComparison.OrdinalIgnoreCase)
|| text.Contains("cannot fast forward", StringComparison.OrdinalIgnoreCase);
}
private static GitCommandResult FailRun(GitRun run, bool suggestTerminal)
=> new()
{
Succeeded = false,
ExitCode = run.ExitCode,
StandardOutput = run.StandardOutput,
StandardError = run.StandardError,
SuggestTerminal = suggestTerminal
};
private static string FirstLine(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return "";
}
var span = text.AsSpan().Trim();
var cut = span.IndexOfAny('\r', '\n');
return cut < 0 ? span.ToString() : span[..cut].ToString();
}
private static GitRun Run(
string git,
string repoRoot,
TimeSpan timeout,
CancellationToken cancellationToken,
IReadOnlyList<string> arguments)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = git,
Arguments = "--no-optional-locks -c core.quotepath=false status --porcelain=v2 -b --untracked-files=normal --ignore-submodules=all",
WorkingDirectory = PathRules.FromExtended(repoRoot),
UseShellExecute = false,
CreateNoWindow = true,
@@ -66,40 +411,48 @@ public sealed class WindowsGitStatusProvider : IGitStatusProvider
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
process.StartInfo.Environment["GIT_TERMINAL_PROMPT"] = "0";
process.StartInfo.Environment["GIT_EDITOR"] = "true";
foreach (var argument in arguments)
{
process.StartInfo.ArgumentList.Add(argument);
}
try
{
if (!process.Start())
{
return null;
return new GitRun(-1, "", "git.exe could not be started.");
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(QueryTimeout);
var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
var stderr = process.StandardError.ReadToEndAsync(timeout.Token);
process.WaitForExit((int)QueryTimeout.TotalMilliseconds);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linked.CancelAfter(timeout);
var stdout = process.StandardOutput.ReadToEndAsync(linked.Token);
var stderr = process.StandardError.ReadToEndAsync(linked.Token);
process.WaitForExit((int)timeout.TotalMilliseconds);
if (!process.HasExited)
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
return null;
return new GitRun(-1, "", "git timed out.");
}
stdout.Wait(timeout.Token);
stderr.Wait(timeout.Token);
return GitPorcelainParser.Parse(stdout.Result, repoRoot);
stdout.Wait(linked.Token);
stderr.Wait(linked.Token);
return new GitRun(process.ExitCode, stdout.Result, stderr.Result);
}
catch (OperationCanceledException)
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
throw;
}
catch
catch (Exception ex)
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
return null;
return new GitRun(-1, "", ex.Message);
}
}
private sealed record CacheEntry(GitStatus? Status, DateTime Utc);
private sealed record GitRun(int ExitCode, string StandardOutput, string StandardError);
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -9,17 +10,42 @@ public sealed class WindowsVolumeService : IVolumeService
{
private readonly ILogger<WindowsVolumeService> _logger;
private IReadOnlyList<VolumeFingerprint>? _onlineCache;
private long _onlineCacheTimestamp;
public WindowsVolumeService(ILogger<WindowsVolumeService> logger) => _logger = logger;
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes()
{
if (_onlineCache is not null && BoundedWait.IsFresh(_onlineCacheTimestamp, TimeSpan.FromSeconds(2)))
{
return _onlineCache;
}
var list = new List<VolumeFingerprint>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
// Mapped network letters are often !IsReady until first access; still expose them.
if (!drive.IsReady && drive.DriveType != DriveType.Network)
if (drive.DriveType == DriveType.Network)
{
var root = PathRules.EnsureDirectoryTrailingSlashIfRoot(drive.Name.TrimEnd('\\'));
if (!root.EndsWith('\\'))
{
root += "\\";
}
list.Add(new VolumeFingerprint
{
Kind = SourceKind.Smb,
RootPath = root,
DisplayName = $"{root.TrimEnd('\\')} (Network)",
Filesystem = "SMB"
});
continue;
}
if (!drive.IsReady)
{
continue;
}
@@ -36,6 +62,8 @@ public sealed class WindowsVolumeService : IVolumeService
}
}
_onlineCache = list;
_onlineCacheTimestamp = Stopwatch.GetTimestamp();
return list;
}
@@ -46,7 +74,7 @@ public sealed class WindowsVolumeService : IVolumeService
if (PathRules.IsUnc(path))
{
var root = PathRules.CanonicalUncRoot(path);
var uncSpace = QuerySpace(root);
var uncSpace = GetSpace(root);
return new VolumeFingerprint
{
Kind = SourceKind.Smb,
@@ -86,11 +114,11 @@ public sealed class WindowsVolumeService : IVolumeService
uint serial = 0;
string? fs = null;
string? label = null;
var space = QuerySpace(rootPath);
var space = GetSpace(rootPath);
long? capacity = space.CapacityBytes;
try
{
if (drive is { IsReady: true })
if (driveType != DriveType.Network && drive is { IsReady: true })
{
fs = drive.DriveFormat;
label = drive.VolumeLabel;
@@ -181,14 +209,36 @@ public sealed class WindowsVolumeService : IVolumeService
}
}
public VolumeSpace GetSpace(string path) => QuerySpace(path);
public VolumeSpace GetSpace(string path)
=> IsRemote(path)
? BoundedWait.Try(() => QuerySpace(path), BoundedWait.RemoteIo, default)
: QuerySpace(path);
public bool IsPathReachable(string path)
{
try
{
var target = PathRules.ToExtended(path);
return Directory.Exists(target) || File.Exists(target);
bool Exists() => Directory.Exists(target) || File.Exists(target);
return IsRemote(path) ? BoundedWait.Try(Exists, BoundedWait.RemoteIo) : Exists();
}
catch
{
return false;
}
}
private static bool IsRemote(string path)
{
if (PathRules.IsUnc(path))
{
return true;
}
try
{
var root = Path.GetPathRoot(path);
return !string.IsNullOrEmpty(root) && new DriveInfo(root).DriveType == DriveType.Network;
}
catch
{

View File

@@ -22,10 +22,10 @@ public sealed class WindowsWorkspaceLauncher : IWorkspaceLauncher
TryStart("cmd.exe", "/k cd /d " + Quote(dir));
}
public bool TryOpenInCursor(string directory)
public bool TryOpenInCursor(string path)
{
var dir = PathRules.FromExtended(directory);
if (!Directory.Exists(dir))
var target = PathRules.FromExtended(path);
if (!Directory.Exists(target) && !File.Exists(target))
{
return false;
}
@@ -33,10 +33,10 @@ public sealed class WindowsWorkspaceLauncher : IWorkspaceLauncher
var cursor = CursorLocator.Find();
if (cursor is not null)
{
return TryStart(cursor, Quote(dir));
return TryStart(cursor, Quote(target));
}
return TryStart("cursor", Quote(dir));
return TryStart("cursor", Quote(target));
}
private static bool TryStart(string fileName, string arguments)