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

@@ -590,12 +590,15 @@ Example:
## Actions ## Actions
- [ ] Status - [x] Status
- [ ] View Changes - [x] View Changes
- [ ] Commit - [x] Diff
- [ ] Pull - [x] Commit
- [ ] Push - [x] Stage / Unstage / Discard
- [ ] Fetch - [x] Merge (ours / theirs / continue / abort)
- [x] Pull
- [x] Push
- [x] Fetch
- [x] Open terminal here - [x] Open terminal here
- [x] Open in Cursor - [x] Open in Cursor
@@ -696,13 +699,9 @@ Potential:
## Git ## Git
`WindowsGitStatusProvider` (`IGitStatusProvider`) `WindowsGitStatusProvider` (`IGitStatusProvider`, `IGitCommandProvider`)
Discovery: Settings path, then Program Files, then PATH. Missing git.exe means no badge. Git is not bundled. Workbench does not commit, push, or pull. Discovery: Settings path, then Program Files, then PATH. Missing git.exe means no badge. Git is not bundled. Status / View Changes lists porcelain paths. Diff is unified `git diff`. Commit stages checked files then `git commit --only`. Stage / unstage / discard, ours / theirs / mark resolved, continue / abort wrap `git.exe`. Fetch / fast-forward pull / merge pull / push use `GIT_TERMINAL_PROMPT=0`. No mergetool, stash, branch UI, or credential dialog. Failed fast-forward pull offers merge pull or a terminal.
Potential later:
`IGitCommandProvider`
--- ---

View File

@@ -24,18 +24,18 @@ Workbench **does**:
Workbench **does not**: Workbench **does not**:
- Two-way sync - Two-way sync
- Commit, push, or pull
- Convert media (no FFmpeg in this build) - Convert media (no FFmpeg in this build)
- Hydrate online-only cloud files just to look at them - Hydrate online-only cloud files just to look at them
- Change Windows drive mappings or cloud client folders - Change Windows drive mappings or cloud client folders
- Replace Git (no stash, branch UI, mergetool, or credential dialog)
Specialized tools still do specialized jobs. 7-Zip compresses. Git reports status. Workbench orchestrates. Specialized tools still do specialized jobs. 7-Zip compresses. Git reports status, shows diffs, commits selected files, resolves conflicts, and runs fetch, pull, and push. Workbench orchestrates.
--- ---
## First launch ## First launch
1. This PC lists local and removable volumes Windows already knows. The window opens immediately. Locations fill in a moment later — Workbench does not wait for slow network shares or a second instance locking the index.
2. Browsing works with an empty index. 2. Browsing works with an empty index.
3. Folder sizes, search, duplicates, and storage analysis need an index. Use the banner **Build index**, **Tools → Locations → Index this location**, or the toolbar **Index** control. 3. Folder sizes, search, duplicates, and storage analysis need an index. Use the banner **Build index**, **Tools → Locations → Index this location**, or the toolbar **Index** control.
4. Data lives under `%LocalAppData%\ExplorerWorkbench\` — never beside the executable. 4. Data lives under `%LocalAppData%\ExplorerWorkbench\` — never beside the executable.
@@ -273,9 +273,17 @@ Never auto-reorganizes. No MIME/content sniffing (that would hydrate cloud files
## Git ## Git
Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind). **Tools → DevelopmentOpen terminal here** and **Open in Cursor** are available when a folder is in context. Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind, merging/rebasing). **Tools → Development** (and the folder context menu) offers **View changes…**, **Commit…**, **Fetch**, **Pull (fast-forward)**, **Pull (merge)**, **Push**, **Open terminal here**, and **Open in Cursor** when a folder is in a repository.
Missing `git.exe` means no badge. Path can be set in Settings. Git is not bundled. There is no commit, push, pull, or diff viewer. Profiles can require a clean working tree. **View changes** lists staged, unstaged, untracked, and unmerged paths from `git status`. Double-click opens a unified **diff** (`git diff` / `git diff --cached`). **Open in Cursor** opens the file. Online-only cloud files are not opened or diffed (that would download them). Stage, unstage, and discard call the matching `git` commands. Discard asks first.
**Commit** asks for a message and which files to include. Unmerged paths, online-only cloud files, and folders are skipped. A merge, rebase, cherry-pick, or revert in progress blocks a normal commit — use **Continue** or **Abort**. Workbench runs `git add` then `git commit --only` for the checked paths, so other staged files stay staged.
**Merge** is conflict resolution, not a mergetool: **Use ours**, **Use theirs**, **Mark resolved**, then **Continue** (`git commit --no-edit` / `rebase --continue`). **Abort** restores the previous state. Incoming rebase/cherry-pick/revert from a terminal can be finished the same way.
**Pull (fast-forward)** is `git pull --ff-only --no-rebase`. If that cannot fast-forward, Workbench offers **Pull (merge)** (`git pull --no-rebase`) or a terminal. Fetch and push are the matching `git` commands. There is no stash, branch UI, mergetool, or credential dialog (`GIT_TERMINAL_PROMPT=0`).
Missing `git.exe` means no badge and no Git actions. Path can be set in Settings. Git is not bundled. Profiles can require a clean working tree.
--- ---

View File

@@ -35,11 +35,21 @@ public partial class App : System.Windows.Application
.Build(); .Build();
var vm = _host.Services.GetRequiredService<MainViewModel>(); var vm = _host.Services.GetRequiredService<MainViewModel>();
await vm.InitializeAsync().ConfigureAwait(true);
await _host.StartAsync().ConfigureAwait(true);
var window = _host.Services.GetRequiredService<MainWindow>(); var window = _host.Services.GetRequiredService<MainWindow>();
vm.PrepareUi();
window.DataContext = vm; window.DataContext = vm;
window.Show(); 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) protected override async void OnExit(ExitEventArgs e)

View File

@@ -42,7 +42,9 @@ public static class AppServices
services.AddSingleton<StorageProviderRegistry>(); services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>(); services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>(); 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<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>(); services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>(); services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
@@ -115,7 +117,7 @@ public sealed class WatcherHostedService : BackgroundService
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false); await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(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(); _transfers.NotifyAvailability();
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false); await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.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 Header="_Operation profiles…" Click="OnOperationProfiles"/>
</MenuItem> </MenuItem>
<MenuItem Header="_Development"> <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}" <MenuItem Header="Open _terminal here" Command="{Binding OpenTerminalCommand}"
IsEnabled="{Binding ShowOpenTerminal}"/> IsEnabled="{Binding ShowOpenTerminal}"/>
<MenuItem Header="Open in _Cursor" Command="{Binding OpenInCursorCommand}" <MenuItem Header="Open in _Cursor" Command="{Binding OpenInCursorCommand}"
@@ -460,6 +474,18 @@
<Separator/> <Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/> <MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/> <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}" <MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/> Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}" <MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"

View File

@@ -16,6 +16,7 @@ namespace Explorer.App;
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private DocumentationWindow? _docs; private DocumentationWindow? _docs;
private GitChangesWindow? _gitChanges;
private Point _dragStart; private Point _dragStart;
private bool _dragPending; private bool _dragPending;
private MouseButton _dragButton; private MouseButton _dragButton;
@@ -39,6 +40,7 @@ public partial class MainWindow : Window
{ {
_wiredVm.InlineRenameRequested -= OnInlineRenameRequested; _wiredVm.InlineRenameRequested -= OnInlineRenameRequested;
_wiredVm.PropertyChanged -= OnViewModelPropertyChanged; _wiredVm.PropertyChanged -= OnViewModelPropertyChanged;
_wiredVm.WorkspaceChanged -= OnWorkspaceChanged;
} }
_wiredVm = DataContext as MainViewModel; _wiredVm = DataContext as MainViewModel;
@@ -46,6 +48,7 @@ public partial class MainWindow : Window
{ {
_wiredVm.InlineRenameRequested += OnInlineRenameRequested; _wiredVm.InlineRenameRequested += OnInlineRenameRequested;
_wiredVm.PropertyChanged += OnViewModelPropertyChanged; _wiredVm.PropertyChanged += OnViewModelPropertyChanged;
_wiredVm.WorkspaceChanged += OnWorkspaceChanged;
RestoreLayout(_wiredVm); RestoreLayout(_wiredVm);
_ = _wiredVm.RefreshUndoRenameAsync(); _ = _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) private void OnInlineRenameRequested(object? sender, string path)
=> Dispatcher.BeginInvoke(() => BeginInlineRenameForPath(path), DispatcherPriority.Loaded); => Dispatcher.BeginInvoke(() => BeginInlineRenameForPath(path), DispatcherPriority.Loaded);
@@ -1480,6 +1486,126 @@ public partial class MainWindow : Window
private void OnAbout(object sender, RoutedEventArgs e) private void OnAbout(object sender, RoutedEventArgs e)
=> new AboutWindow { Owner = this }.ShowDialog(); => 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() private void ShowDocumentation()
{ {
if (_docs is { IsVisible: true }) if (_docs is { IsVisible: true })

View File

@@ -72,7 +72,7 @@
<TextBlock Text="Git" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/> <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" <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"> <DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/> <Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
<TextBox x:Name="GitPath"/> <TextBox x:Name="GitPath"/>

View File

@@ -19,4 +19,9 @@
<SolidColorBrush x:Key="ScrollThumb" Color="#5A5A5E"/> <SolidColorBrush x:Key="ScrollThumb" Color="#5A5A5E"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#7A7A7E"/> <SolidColorBrush x:Key="ScrollThumbHover" Color="#7A7A7E"/>
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#9A9A9E"/> <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> </ResourceDictionary>

View File

@@ -19,4 +19,9 @@
<SolidColorBrush x:Key="ScrollThumb" Color="#B0B0B0"/> <SolidColorBrush x:Key="ScrollThumb" Color="#B0B0B0"/>
<SolidColorBrush x:Key="ScrollThumbHover" Color="#8A8A8A"/> <SolidColorBrush x:Key="ScrollThumbHover" Color="#8A8A8A"/>
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#6A6A6A"/> <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> </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 System.Text.RegularExpressions;
using Explorer.Domain; using Explorer.Domain;
@@ -20,6 +21,7 @@ public static class GitPorcelainParser
var behind = 0; var behind = 0;
var modified = 0; var modified = 0;
var untracked = 0; var untracked = 0;
var changes = new List<GitChange>();
foreach (var raw in output.Split(["\r\n", "\n"], StringSplitOptions.None)) foreach (var raw in output.Split(["\r\n", "\n"], StringSplitOptions.None))
{ {
var line = raw.TrimEnd(); var line = raw.TrimEnd();
@@ -60,6 +62,18 @@ public static class GitPorcelainParser
if (line.StartsWith("? ", StringComparison.Ordinal)) if (line.StartsWith("? ", StringComparison.Ordinal))
{ {
untracked++; untracked++;
var path = ParsePath(RemainderAfter(line, 1));
if (path is not null)
{
changes.Add(new GitChange
{
Path = path,
Index = '?',
WorkTree = '?',
State = GitChangeState.Untracked
});
}
continue; continue;
} }
@@ -68,11 +82,37 @@ public static class GitPorcelainParser
continue; continue;
} }
if (line.StartsWith("1 ", StringComparison.Ordinal) if (line.StartsWith("u ", StringComparison.Ordinal))
|| line.StartsWith("2 ", StringComparison.Ordinal)
|| line.StartsWith("u ", StringComparison.Ordinal))
{ {
modified++; 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"; branch = "HEAD";
} }
changes.Sort(CompareChanges);
return new GitStatus return new GitStatus
{ {
RepoRoot = repoRoot, RepoRoot = repoRoot,
@@ -92,7 +133,188 @@ public static class GitPorcelainParser
ModifiedCount = modified, ModifiedCount = modified,
UntrackedCount = untracked, UntrackedCount = untracked,
Ahead = ahead, 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; 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 public interface IWorkspaceLauncher
{ {
void OpenTerminal(string directory); 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;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -12,6 +13,11 @@ public sealed class SourceManager
private readonly IClock _clock; private readonly IClock _clock;
private readonly ILogger<SourceManager> _logger; 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( public SourceManager(
IIndexStore store, IIndexStore store,
IVolumeService volumes, IVolumeService volumes,
@@ -34,8 +40,37 @@ public sealed class SourceManager
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false); 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 known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
var online = _volumes.EnumerateOnlineVolumes(); var online = _volumes.EnumerateOnlineVolumes();
var seenIds = new HashSet<long>(); var seenIds = new HashSet<long>();
@@ -47,6 +82,7 @@ public sealed class SourceManager
if (match.Source is not null && !match.Ambiguous) if (match.Source is not null && !match.Ambiguous)
{ {
source = match.Source; source = match.Source;
var wasOffline = source.Status == SourceStatus.Offline;
source.LastRootPath = fp.RootPath; source.LastRootPath = fp.RootPath;
source.DisplayName = fp.DisplayName ?? source.DisplayName; source.DisplayName = fp.DisplayName ?? source.DisplayName;
source.Label = fp.Label ?? source.Label; source.Label = fp.Label ?? source.Label;
@@ -58,10 +94,16 @@ public sealed class SourceManager
source.LastSeenUtc = _clock.UtcNow; source.LastSeenUtc = _clock.UtcNow;
source.Status = await ResolveReachableStatusAsync(source, cancellationToken).ConfigureAwait(false); source.Status = await ResolveReachableStatusAsync(source, cancellationToken).ConfigureAwait(false);
source.LastError = null; source.LastError = null;
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false); await TryIndexWrite(
if (source.IsIndexed) () => _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()) else if (fp.Kind.IsNetwork())
@@ -99,17 +141,24 @@ public sealed class SourceManager
continue; continue;
} }
var reachable = source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath); if (source.LastRootPath is not null
if (reachable) && (source.Kind.IsNetwork() || PathRules.IsUnc(source.LastRootPath))
&& _volumes.IsPathReachable(source.LastRootPath))
{ {
continue; continue;
} }
if (source.Status != SourceStatus.Offline) if (source.Status != SourceStatus.Offline)
{ {
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Offline, null, cancellationToken) await TryIndexWrite(
.ConfigureAwait(false); () => _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Offline, null, cancellationToken),
await _store.Entries.MarkSourceOfflineAsync(source.Id, cancellationToken).ConfigureAwait(false); "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); 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) 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; existing.Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline;
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false); await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
RememberUnc(root); RememberUnc(root);
InvalidateRefreshCache();
return existing; return existing;
} }
@@ -163,6 +235,7 @@ public sealed class SourceManager
}; };
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false); source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
RememberUnc(root); RememberUnc(root);
InvalidateRefreshCache();
return source; return source;
} }
@@ -210,6 +283,7 @@ public sealed class SourceManager
} }
_logger.LogInformation("Forgot disconnected source {DisplayName} ({Path})", source.DisplayName, source.LastRootPath); _logger.LogInformation("Forgot disconnected source {DisplayName} ({Path})", source.DisplayName, source.LastRootPath);
InvalidateRefreshCache();
return true; return true;
} }
@@ -258,6 +332,7 @@ public sealed class SourceManager
? await ResolveReachableStatusAsync(existing, cancellationToken).ConfigureAwait(false) ? await ResolveReachableStatusAsync(existing, cancellationToken).ConfigureAwait(false)
: SourceStatus.Offline; : SourceStatus.Offline;
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false); await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return existing; return existing;
} }
@@ -280,6 +355,7 @@ public sealed class SourceManager
source.LastSeenUtc = _clock.UtcNow; source.LastSeenUtc = _clock.UtcNow;
source.Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline; source.Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline;
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false); await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return source; return source;
} }
@@ -303,6 +379,7 @@ public sealed class SourceManager
LastSeenUtc = _clock.UtcNow LastSeenUtc = _clock.UtcNow
}; };
created.Id = await _store.Sources.UpsertAsync(created, cancellationToken).ConfigureAwait(false); created.Id = await _store.Sources.UpsertAsync(created, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return created; 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) public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
=> _store.Sources.GetAsync(id, cancellationToken); => _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; 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 sealed record GitStatus
{ {
public required string RepoRoot { get; init; } public required string RepoRoot { get; init; }
@@ -8,14 +115,31 @@ public sealed record GitStatus
public int UntrackedCount { get; init; } public int UntrackedCount { get; init; }
public int Ahead { get; init; } public int Ahead { get; init; }
public int Behind { 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 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 public string Badge
{ {
get get
{ {
var parts = new List<string> { Branch }; var parts = new List<string> { Branch };
if (!string.IsNullOrEmpty(OperationLabel))
{
parts.Add(OperationLabel);
}
if (ModifiedCount > 0) if (ModifiedCount > 0)
{ {
parts.Add($"{ModifiedCount} modified"); parts.Add($"{ModifiedCount} modified");
@@ -36,7 +160,7 @@ public sealed record GitStatus
parts.Add($"{Behind} behind"); parts.Add($"{Behind} behind");
} }
if (WorkingTreeClean && Ahead == 0 && Behind == 0) if (WorkingTreeClean && Ahead == 0 && Behind == 0 && Operation == GitOperationKind.None)
{ {
parts.Add("clean"); parts.Add("clean");
} }

View File

@@ -294,6 +294,12 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
ApplyCurrentSort(); ApplyCurrentSort();
} }
public Task RefreshGitAsync()
{
var ct = _loadCts?.Token ?? CancellationToken.None;
return ApplyGitAsync(CurrentPath, ct);
}
private async Task ApplyGitAsync(string path, CancellationToken cancellationToken) private async Task ApplyGitAsync(string path, CancellationToken cancellationToken)
{ {
GitBadge = ""; 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 OperationProfileService _operationProfiles;
private readonly ReorganizeService _reorganize; private readonly ReorganizeService _reorganize;
private readonly IGitStatusProvider _git; private readonly IGitStatusProvider _git;
private readonly IGitCommandProvider _gitCommands;
private readonly IWorkspaceLauncher _workspace; private readonly IWorkspaceLauncher _workspace;
private readonly IHydrationGuard _hydration;
private readonly IThumbnailService? _thumbnails; private readonly IThumbnailService? _thumbnails;
private List<string> _clipboard = []; private List<string> _clipboard = [];
private bool _clipboardIsCut; private bool _clipboardIsCut;
@@ -80,6 +82,8 @@ public sealed partial class MainViewModel : ObservableObject
ReorganizeService reorganize, ReorganizeService reorganize,
IGitStatusProvider git, IGitStatusProvider git,
IWorkspaceLauncher workspace, IWorkspaceLauncher workspace,
IGitCommandProvider gitCommands,
IHydrationGuard hydration,
IThumbnailService? thumbnails = null) IThumbnailService? thumbnails = null)
{ {
_browse = browse; _browse = browse;
@@ -96,7 +100,9 @@ public sealed partial class MainViewModel : ObservableObject
_operationProfiles = operationProfiles; _operationProfiles = operationProfiles;
_reorganize = reorganize; _reorganize = reorganize;
_git = git; _git = git;
_gitCommands = gitCommands;
_workspace = workspace; _workspace = workspace;
_hydration = hydration;
_thumbnails = thumbnails; _thumbnails = thumbnails;
var prefs = preferences.Load(); var prefs = preferences.Load();
Theme = prefs.Theme; Theme = prefs.Theme;
@@ -154,16 +160,36 @@ public sealed partial class MainViewModel : ObservableObject
public DuplicateViewModel Duplicates { get; } public DuplicateViewModel Duplicates { get; }
public TransferQueueViewModel Transfers { get; } public TransferQueueViewModel Transfers { get; }
public ExplorerPaneViewModel ActivePane => ActiveTab.ActivePane; 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() public async Task InitializeAsync()
{ {
PrepareUi();
await _sources.InitializeAsync().ConfigureAwait(true); await _sources.InitializeAsync().ConfigureAwait(true);
foreach (var path in _pathHistory.Load()) foreach (var path in _pathHistory.Load())
{ {
PathHistory.Add(path); PathHistory.Add(path);
} }
await NewTabAsync().ConfigureAwait(true); await ActiveTab.OpenInitialAsync().ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true); await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
Footer = "Ready"; Footer = "Ready";
} }
@@ -457,6 +483,89 @@ public sealed partial class MainViewModel : ObservableObject
public ReorganizeViewModel CreateReorganizeViewModel() public ReorganizeViewModel CreateReorganizeViewModel()
=> new(_reorganize, OrganizeSourcePath()); => 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 string? OrganizeSourcePath() => WorkspaceDirectory();
public Task<IReadOnlyList<OperationProfile>> ListOperationProfilesAsync() public Task<IReadOnlyList<OperationProfile>> ListOperationProfilesAsync()
@@ -1015,6 +1124,7 @@ public sealed partial class MainViewModel : ObservableObject
{ {
PathText = tab.ActivePane.CurrentPath; PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane)); OnPropertyChanged(nameof(ActivePane));
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
} }
}; };
} }
@@ -1031,6 +1141,7 @@ public sealed partial class MainViewModel : ObservableObject
PathText = tab.ActivePane.CurrentPath; PathText = tab.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane)); OnPropertyChanged(nameof(ActivePane));
RefreshCloudActions(); RefreshCloudActions();
WorkspaceChanged?.Invoke(this, EventArgs.Empty);
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath); _ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
} }
else if (propertyName is nameof(ExplorerPaneViewModel.GitBadge) else if (propertyName is nameof(ExplorerPaneViewModel.GitBadge)
@@ -1208,5 +1319,6 @@ public sealed partial class MainViewModel : ObservableObject
{ {
PathText = value.ActivePane.CurrentPath; PathText = value.ActivePane.CurrentPath;
OnPropertyChanged(nameof(ActivePane)); 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 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) public async Task ReloadAsync(string? revealPath = null, CancellationToken cancellationToken = default)
{ {
var expanded = new List<string>(); var expanded = new List<string>();

View File

@@ -6,10 +6,13 @@ using Explorer.Domain;
namespace Explorer.Windows; 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 CacheTtl = TimeSpan.FromSeconds(4);
private static readonly TimeSpan QueryTimeout = 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 UiPreferencesStore _preferences;
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase); 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 bool IsRepoRoot(string path) => GitRepoDetector.IsRepoRoot(path);
public Task<GitStatus?> GetStatusAsync(string path, CancellationToken cancellationToken = default) 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(); cancellationToken.ThrowIfCancellationRequested();
var root = FindRepoRoot(path); var root = FindRepoRoot(path);
@@ -34,7 +97,7 @@ public sealed class WindowsGitStatusProvider : IGitStatusProvider
return null; 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; return hit.Status;
} }
@@ -46,18 +109,300 @@ public sealed class WindowsGitStatusProvider : IGitStatusProvider
return null; 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); _cache[root] = new CacheEntry(status, DateTime.UtcNow);
return status; 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(); using var process = new Process();
process.StartInfo = new ProcessStartInfo process.StartInfo = new ProcessStartInfo
{ {
FileName = git, FileName = git,
Arguments = "--no-optional-locks -c core.quotepath=false status --porcelain=v2 -b --untracked-files=normal --ignore-submodules=all",
WorkingDirectory = PathRules.FromExtended(repoRoot), WorkingDirectory = PathRules.FromExtended(repoRoot),
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true,
@@ -66,40 +411,48 @@ public sealed class WindowsGitStatusProvider : IGitStatusProvider
StandardOutputEncoding = Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = 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 try
{ {
if (!process.Start()) if (!process.Start())
{ {
return null; return new GitRun(-1, "", "git.exe could not be started.");
} }
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(QueryTimeout); linked.CancelAfter(timeout);
var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token); var stdout = process.StandardOutput.ReadToEndAsync(linked.Token);
var stderr = process.StandardError.ReadToEndAsync(timeout.Token); var stderr = process.StandardError.ReadToEndAsync(linked.Token);
process.WaitForExit((int)QueryTimeout.TotalMilliseconds); process.WaitForExit((int)timeout.TotalMilliseconds);
if (!process.HasExited) if (!process.HasExited)
{ {
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
return null; return new GitRun(-1, "", "git timed out.");
} }
stdout.Wait(timeout.Token); stdout.Wait(linked.Token);
stderr.Wait(timeout.Token); stderr.Wait(linked.Token);
return GitPorcelainParser.Parse(stdout.Result, repoRoot); return new GitRun(process.ExitCode, stdout.Result, stderr.Result);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
throw; throw;
} }
catch catch (Exception ex)
{ {
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } 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 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 System.Runtime.InteropServices;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions; using Explorer.Domain.Abstractions;
@@ -9,17 +10,42 @@ public sealed class WindowsVolumeService : IVolumeService
{ {
private readonly ILogger<WindowsVolumeService> _logger; private readonly ILogger<WindowsVolumeService> _logger;
private IReadOnlyList<VolumeFingerprint>? _onlineCache;
private long _onlineCacheTimestamp;
public WindowsVolumeService(ILogger<WindowsVolumeService> logger) => _logger = logger; public WindowsVolumeService(ILogger<WindowsVolumeService> logger) => _logger = logger;
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes()
{ {
if (_onlineCache is not null && BoundedWait.IsFresh(_onlineCacheTimestamp, TimeSpan.FromSeconds(2)))
{
return _onlineCache;
}
var list = new List<VolumeFingerprint>(); var list = new List<VolumeFingerprint>();
foreach (var drive in DriveInfo.GetDrives()) foreach (var drive in DriveInfo.GetDrives())
{ {
try try
{ {
// Mapped network letters are often !IsReady until first access; still expose them. if (drive.DriveType == DriveType.Network)
if (!drive.IsReady && 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; continue;
} }
@@ -36,6 +62,8 @@ public sealed class WindowsVolumeService : IVolumeService
} }
} }
_onlineCache = list;
_onlineCacheTimestamp = Stopwatch.GetTimestamp();
return list; return list;
} }
@@ -46,7 +74,7 @@ public sealed class WindowsVolumeService : IVolumeService
if (PathRules.IsUnc(path)) if (PathRules.IsUnc(path))
{ {
var root = PathRules.CanonicalUncRoot(path); var root = PathRules.CanonicalUncRoot(path);
var uncSpace = QuerySpace(root); var uncSpace = GetSpace(root);
return new VolumeFingerprint return new VolumeFingerprint
{ {
Kind = SourceKind.Smb, Kind = SourceKind.Smb,
@@ -86,11 +114,11 @@ public sealed class WindowsVolumeService : IVolumeService
uint serial = 0; uint serial = 0;
string? fs = null; string? fs = null;
string? label = null; string? label = null;
var space = QuerySpace(rootPath); var space = GetSpace(rootPath);
long? capacity = space.CapacityBytes; long? capacity = space.CapacityBytes;
try try
{ {
if (drive is { IsReady: true }) if (driveType != DriveType.Network && drive is { IsReady: true })
{ {
fs = drive.DriveFormat; fs = drive.DriveFormat;
label = drive.VolumeLabel; 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) public bool IsPathReachable(string path)
{ {
try try
{ {
var target = PathRules.ToExtended(path); 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 catch
{ {

View File

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

View File

@@ -19,5 +19,6 @@
<ProjectReference Include="..\..\src\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" /> <ProjectReference Include="..\..\src\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" /> <ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" /> <ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\..\src\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,171 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Windows;
namespace Explorer.Application.Tests;
public class GitCommandIntegrationTests : IDisposable
{
private readonly string _root;
private readonly WindowsGitStatusProvider _git;
public GitCommandIntegrationTests()
{
_root = Path.Combine(Path.GetTempPath(), "ew-git", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_git = new WindowsGitStatusProvider(new UiPreferencesStore(new GitTestEnv(_root)));
}
public void Dispose()
{
try { Directory.Delete(_root, true); } catch { /* ignore */ }
}
[Fact]
public async Task Diff_stage_commit_and_merge_resolution()
{
if (GitLocator.Find(null) is null)
{
return;
}
var repo = Path.Combine(_root, "repo");
Directory.CreateDirectory(repo);
await Git(repo, "init", "-b", "main").ConfigureAwait(true);
await Git(repo, "config", "user.email", "test@example.com").ConfigureAwait(true);
await Git(repo, "config", "user.name", "Test").ConfigureAwait(true);
await Git(repo, "config", "core.autocrlf", "false").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\n");
await Git(repo, "add", "note.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "base").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\nedited\n");
var status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
var unstaged = Assert.Single(status.Changes, c => c.State == GitChangeState.Unstaged);
var diff = await _git.DiffAsync(repo, unstaged).ConfigureAwait(true);
Assert.Null(diff.Error);
Assert.Contains(diff.Lines, l => l.Kind == GitDiffLineKind.Added && l.Text.Contains("edited"));
var staged = await _git.StageAsync(repo, "note.txt").ConfigureAwait(true);
Assert.True(staged.Succeeded, staged.DisplayMessage);
var committed = await _git.CommitAsync(repo, "edit", ["note.txt"]).ConfigureAwait(true);
Assert.True(committed.Succeeded, committed.DisplayMessage);
await Git(repo, "checkout", "-b", "topic").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\nedited\ntopic\n");
await Git(repo, "add", "note.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "topic").ConfigureAwait(true);
await Git(repo, "checkout", "main").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "note.txt"), "base\nedited\nmain\n");
await Git(repo, "add", "note.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "main").ConfigureAwait(true);
var merge = await GitExpectFail(repo, "merge", "topic").ConfigureAwait(true);
Assert.False(string.IsNullOrWhiteSpace(merge));
status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.Equal(GitOperationKind.Merge, status.Operation);
Assert.Contains(status.Changes, c => c.State == GitChangeState.Unmerged);
var ours = await _git.CheckoutConflictAsync(repo, "note.txt", GitConflictSide.Ours).ConfigureAwait(true);
Assert.True(ours.Succeeded, ours.DisplayMessage);
status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.False(status.HasUnmerged);
var continued = await _git.ContinueOperationAsync(repo).ConfigureAwait(true);
Assert.True(continued.Succeeded, continued.DisplayMessage);
status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.Equal(GitOperationKind.None, status.Operation);
Assert.True(status.WorkingTreeClean);
}
[Fact]
public async Task Abort_restores_the_pre_merge_tree()
{
if (GitLocator.Find(null) is null)
{
return;
}
var repo = Path.Combine(_root, "abort");
Directory.CreateDirectory(repo);
await Git(repo, "init", "-b", "main").ConfigureAwait(true);
await Git(repo, "config", "user.email", "test@example.com").ConfigureAwait(true);
await Git(repo, "config", "user.name", "Test").ConfigureAwait(true);
await Git(repo, "config", "core.autocrlf", "false").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "a.txt"), "one\n");
await Git(repo, "add", "a.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "one").ConfigureAwait(true);
await Git(repo, "checkout", "-b", "other").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "a.txt"), "two\n");
await Git(repo, "add", "a.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "two").ConfigureAwait(true);
await Git(repo, "checkout", "main").ConfigureAwait(true);
File.WriteAllText(Path.Combine(repo, "a.txt"), "three\n");
await Git(repo, "add", "a.txt").ConfigureAwait(true);
await Git(repo, "commit", "-m", "three").ConfigureAwait(true);
await GitExpectFail(repo, "merge", "other").ConfigureAwait(true);
var aborted = await _git.AbortOperationAsync(repo).ConfigureAwait(true);
Assert.True(aborted.Succeeded, aborted.DisplayMessage);
var status = await _git.StatusAsync(repo).ConfigureAwait(true);
Assert.NotNull(status);
Assert.Equal(GitOperationKind.None, status.Operation);
Assert.Equal("three\n", File.ReadAllText(Path.Combine(repo, "a.txt")).Replace("\r\n", "\n"));
}
private static async Task Git(string repo, params string[] args)
{
var result = await RunGit(repo, args).ConfigureAwait(true);
Assert.True(result.Exit == 0, result.Text);
}
private static async Task<string> GitExpectFail(string repo, params string[] args)
{
var result = await RunGit(repo, args).ConfigureAwait(true);
Assert.NotEqual(0, result.Exit);
return result.Text;
}
private static async Task<(int Exit, string Text)> RunGit(string repo, string[] args)
{
var git = GitLocator.Find(null) ?? throw new InvalidOperationException("git.exe missing");
using var process = new System.Diagnostics.Process();
process.StartInfo.FileName = git;
process.StartInfo.WorkingDirectory = repo;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(true);
var stderr = await process.StandardError.ReadToEndAsync().ConfigureAwait(true);
await process.WaitForExitAsync().ConfigureAwait(true);
return (process.ExitCode, stdout + stderr);
}
}
file sealed class GitTestEnv : IAppEnvironment
{
public GitTestEnv(string dir)
{
DataDirectory = Path.Combine(dir, "data");
Directory.CreateDirectory(DataDirectory);
DatabasePath = Path.Combine(DataDirectory, "index.db");
LogDirectory = Path.Combine(DataDirectory, "logs");
Directory.CreateDirectory(LogDirectory);
}
public string DataDirectory { get; }
public string DatabasePath { get; }
public string LogDirectory { get; }
}

View File

@@ -0,0 +1,114 @@
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Application.Tests;
public class GitCommitPlannerTests
{
private static readonly HashSet<string> None = new(StringComparer.OrdinalIgnoreCase);
[Fact]
public void Rejects_an_empty_message()
{
var plan = GitCommitPlanner.Create(
" ",
[Change("src/App.cs")],
None,
None,
operationInProgress: false);
Assert.False(plan.CanCommit);
Assert.Equal("Enter a commit message.", plan.Error);
}
[Fact]
public void Rejects_merge_or_rebase_in_progress()
{
var plan = GitCommitPlanner.Create(
"wip",
[Change("src/App.cs")],
None,
None,
operationInProgress: true);
Assert.False(plan.CanCommit);
Assert.Contains("merge or rebase", plan.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Skips_unmerged_hydration_and_directories()
{
var plan = GitCommitPlanner.Create(
"save",
[
Change("conflict.txt", GitChangeState.Unmerged, 'U', 'U'),
Change("cloud.bin"),
Change("src"),
],
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "cloud.bin" },
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "src" },
operationInProgress: false);
Assert.False(plan.CanCommit);
Assert.Equal(["conflict.txt"], plan.SkippedUnmerged);
Assert.Equal(["cloud.bin"], plan.SkippedHydration);
Assert.Equal(["src"], plan.SkippedDirectories);
Assert.Contains("online-only", plan.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Dedupes_staged_and_unstaged_of_the_same_path()
{
var plan = GitCommitPlanner.Create(
"both",
[
Change("src/Both.cs", GitChangeState.Staged, 'M', '.'),
Change("src/Both.cs", GitChangeState.Unstaged, '.', 'M'),
],
None,
None,
operationInProgress: false);
Assert.True(plan.CanCommit);
Assert.Equal(["src/Both.cs"], plan.Paths);
}
[Fact]
public void Commits_files_and_deleted_paths_while_skipping_the_rest()
{
var plan = GitCommitPlanner.Create(
"mixed",
[
Change("keep.cs"),
Change("gone.cs", GitChangeState.Unstaged, '.', 'D'),
Change("conflict.txt", GitChangeState.Unmerged, 'U', 'U'),
Change("cloud.bin"),
Change("src"),
],
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "cloud.bin" },
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "src", "gone.cs" },
operationInProgress: false);
Assert.True(plan.CanCommit);
Assert.Equal(["keep.cs", "gone.cs"], plan.Paths);
Assert.Equal(["conflict.txt"], plan.SkippedUnmerged);
Assert.Equal(["cloud.bin"], plan.SkippedHydration);
Assert.Equal(["src"], plan.SkippedDirectories);
}
[Fact]
public void Requires_at_least_one_file()
{
var plan = GitCommitPlanner.Create("msg", [], None, None, operationInProgress: false);
Assert.False(plan.CanCommit);
Assert.Equal("Select at least one file to commit.", plan.Error);
}
private static GitChange Change(
string path,
GitChangeState state = GitChangeState.Unstaged,
char index = '.',
char workTree = 'M')
=> new()
{
Path = path,
State = state,
Index = index,
WorkTree = workTree
};
}

View File

@@ -0,0 +1,86 @@
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Application.Tests;
public class GitDiffPlannerTests
{
[Fact]
public void Staged_diff_does_not_read_the_working_tree()
{
var request = GitDiffPlanner.Create(new GitChange
{
Path = "src/App.cs",
State = GitChangeState.Staged,
Index = 'M',
WorkTree = '.'
});
Assert.Null(request.Error);
Assert.False(request.NeedsWorkingTree);
Assert.Contains("--cached", request.Arguments);
Assert.Contains("src/App.cs", request.Arguments);
}
[Fact]
public void Untracked_diff_uses_no_index_against_dev_null()
{
var request = GitDiffPlanner.Create(new GitChange
{
Path = "new.txt",
State = GitChangeState.Untracked,
Index = '?',
WorkTree = '?'
});
Assert.True(request.NeedsWorkingTree);
Assert.Contains("--no-index", request.Arguments);
Assert.Contains("/dev/null", request.Arguments);
Assert.Contains("new.txt", request.Arguments);
}
[Fact]
public void Deleted_unstaged_diff_does_not_need_the_working_tree()
{
var request = GitDiffPlanner.Create(new GitChange
{
Path = "gone.cs",
State = GitChangeState.Unstaged,
Index = '.',
WorkTree = 'D'
});
Assert.False(request.NeedsWorkingTree);
Assert.DoesNotContain("--cached", request.Arguments);
}
}
public class GitDiffParserTests
{
[Fact]
public void Classifies_unified_diff_lines()
{
var diff = GitDiffParser.Parse("""
diff --git a/src/App.cs b/src/App.cs
index 111..222 100644
--- a/src/App.cs
+++ b/src/App.cs
@@ -1,3 +1,4 @@
keep
-old
+new
still
""", "src/App.cs");
Assert.Equal("src/App.cs", diff.Title);
Assert.False(diff.IsBinary);
Assert.Equal(GitDiffLineKind.Meta, diff.Lines[0].Kind);
Assert.Equal(GitDiffLineKind.Hunk, diff.Lines[4].Kind);
Assert.Equal(GitDiffLineKind.Context, diff.Lines[5].Kind);
Assert.Equal(GitDiffLineKind.Removed, diff.Lines[6].Kind);
Assert.Equal(GitDiffLineKind.Added, diff.Lines[7].Kind);
}
[Fact]
public void Marks_binary_and_empty()
{
Assert.True(GitDiffParser.Parse("Binary files a/x and b/x differ\n", "x").IsBinary);
Assert.True(GitDiffParser.Parse("", "x").IsEmpty);
}
}

View File

@@ -15,7 +15,7 @@ public class GitPorcelainParserTests
# branch.ab +2 -1 # branch.ab +2 -1
1 .M N... 100644 100644 100644 a a src/App.cs 1 .M N... 100644 100644 100644 a a src/App.cs
1 M. N... 100644 100644 100644 b b README.md 1 M. N... 100644 100644 100644 b b README.md
2 R. N... 100644 100644 100644 c c R100 old.txt new.txt 2 R. N... 100644 100644 100644 c c R100 new.txt old.txt
? bin/out.dll ? bin/out.dll
? notes.md ? notes.md
! ignore.me ! ignore.me
@@ -28,8 +28,79 @@ public class GitPorcelainParserTests
Assert.Equal(1, status.Behind); Assert.Equal(1, status.Behind);
Assert.False(status.WorkingTreeClean); Assert.False(status.WorkingTreeClean);
Assert.Equal("main · 3 modified · 2 untracked · 2 ahead · 1 behind", status.Badge); Assert.Equal("main · 3 modified · 2 untracked · 2 ahead · 1 behind", status.Badge);
Assert.Collection(
status.Changes,
c =>
{
Assert.Equal(GitChangeState.Staged, c.State);
Assert.Equal("old.txt", c.OriginalPath);
Assert.Equal("new.txt", c.Path);
Assert.Equal("old.txt → new.txt", c.DisplayPath);
Assert.Equal("renamed", c.ChangeLabel);
},
c =>
{
Assert.Equal(GitChangeState.Staged, c.State);
Assert.Equal("README.md", c.Path);
Assert.Equal("modified", c.ChangeLabel);
},
c =>
{
Assert.Equal(GitChangeState.Unstaged, c.State);
Assert.Equal("src/App.cs", c.Path);
Assert.Equal("modified", c.ChangeLabel);
},
c =>
{
Assert.Equal(GitChangeState.Untracked, c.State);
Assert.Equal("bin/out.dll", c.Path);
},
c =>
{
Assert.Equal(GitChangeState.Untracked, c.State);
Assert.Equal("notes.md", c.Path);
});
} }
[Fact]
public void Lists_staged_and_unstaged_for_the_same_path()
{
var status = GitPorcelainParser.Parse("""
# branch.head main
1 MM N... 100644 100644 100644 a a src/Both.cs
""", @"C:\src");
Assert.NotNull(status);
Assert.Equal(1, status.ModifiedCount);
Assert.Equal(2, status.Changes.Count);
Assert.Equal(GitChangeState.Staged, status.Changes[0].State);
Assert.Equal(GitChangeState.Unstaged, status.Changes[1].State);
Assert.All(status.Changes, c => Assert.Equal("src/Both.cs", c.Path));
}
[Fact]
public void Lists_unmerged_and_quoted_paths()
{
var status = GitPorcelainParser.Parse("""
# branch.head topic
u UU N... 100644 100644 100644 100644 a b c conflict.txt
1 .M N... 100644 100644 100644 a a "my file.txt"
? docs/a file.md
""", @"C:\src");
Assert.NotNull(status);
Assert.Equal(2, status.ModifiedCount);
Assert.Equal(1, status.UntrackedCount);
Assert.Equal(GitChangeState.Unmerged, status.Changes[0].State);
Assert.Equal("conflict.txt", status.Changes[0].Path);
Assert.Equal("unmerged", status.Changes[0].ChangeLabel);
Assert.Equal("my file.txt", status.Changes[1].Path);
Assert.Equal("docs/a file.md", status.Changes[2].Path);
}
[Fact]
public void Unquote_decodes_c_escapes()
=> Assert.Equal("a\"b", GitPorcelainParser.Unquote("\"a\\\"b\""));
[Fact] [Fact]
public void Clean_tree_uses_detached_oid_prefix() public void Clean_tree_uses_detached_oid_prefix()
{ {
@@ -95,4 +166,57 @@ public class GitRepoDetectorTests
[Fact] [Fact]
public void Virtual_roots_are_not_repos() public void Virtual_roots_are_not_repos()
=> Assert.Null(GitRepoDetector.FindRoot(LocationRoots.ThisPc, _ => true, _ => true)); => Assert.Null(GitRepoDetector.FindRoot(LocationRoots.ThisPc, _ => true, _ => true));
[Fact]
public void Finds_gitdir_from_a_directory_and_a_gitfile()
{
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@"C:\src",
@"C:\src\.git",
@"C:\work",
@"D:\repo\.git\worktrees\topic"
};
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { @"C:\work\.git" };
Assert.Equal(@"C:\src\.git", GitRepoDetector.FindGitDir(@"C:\src", dirs.Contains, files.Contains));
Assert.Equal(
@"D:\repo\.git\worktrees\topic",
GitRepoDetector.FindGitDir(
@"C:\work",
dirs.Contains,
files.Contains,
_ => "gitdir: D:/repo/.git/worktrees/topic"));
}
[Fact]
public void Detects_merge_and_rebase_in_progress()
{
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@"C:\src",
@"C:\src\.git",
@"C:\src\.git\rebase-merge",
@"C:\work",
@"D:\repo\.git\worktrees\topic"
};
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
@"C:\src\.git\MERGE_HEAD",
@"C:\work\.git",
@"D:\repo\.git\worktrees\topic\CHERRY_PICK_HEAD"
};
Assert.Equal(GitOperationKind.Merge, GitRepoDetector.GetOperation(@"C:\src", dirs.Contains, files.Contains));
Assert.True(GitRepoDetector.IsOperationInProgress(@"C:\src", dirs.Contains, files.Contains));
dirs.Remove(@"C:\src\.git\rebase-merge");
files.Remove(@"C:\src\.git\MERGE_HEAD");
Assert.Equal(GitOperationKind.None, GitRepoDetector.GetOperation(@"C:\src", dirs.Contains, files.Contains));
Assert.False(GitRepoDetector.IsOperationInProgress(@"C:\src", dirs.Contains, files.Contains));
Assert.Equal(
GitOperationKind.CherryPick,
GitRepoDetector.GetOperation(
@"C:\work",
dirs.Contains,
files.Contains,
_ => "gitdir: D:/repo/.git/worktrees/topic"));
}
} }

View File

@@ -45,7 +45,7 @@ public class SourceManagerTests
CapacityBytes = 64 CapacityBytes = 64
} }
]; ];
await mgr.RefreshOnlineStateAsync(); await mgr.RefreshOnlineStateAsync(forceRefresh: true);
var again = (await store.Sources.GetAllAsync()).Single(); var again = (await store.Sources.GetAllAsync()).Single();
Assert.Equal(key, again.StableKey); Assert.Equal(key, again.StableKey);
Assert.Equal(@"G:\", again.LastRootPath); Assert.Equal(@"G:\", again.LastRootPath);
@@ -129,7 +129,7 @@ public class SourceManagerTests
Assert.False(await mgr.ForgetDisconnectedAsync(@"Z:\")); Assert.False(await mgr.ForgetDisconnectedAsync(@"Z:\"));
volumes.Online = []; volumes.Online = [];
await mgr.RefreshOnlineStateAsync(); await mgr.RefreshOnlineStateAsync(forceRefresh: true);
source = (await store.Sources.GetAllAsync()).Single(); source = (await store.Sources.GetAllAsync()).Single();
Assert.True(mgr.CanForget(source)); Assert.True(mgr.CanForget(source));
Assert.True(await mgr.ForgetDisconnectedAsync(@"Z:\")); Assert.True(await mgr.ForgetDisconnectedAsync(@"Z:\"));
@@ -164,7 +164,7 @@ public class SourceManagerTests
await store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Scanning, null); await store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Scanning, null);
await store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, 1); await store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, 1);
await mgr.RefreshOnlineStateAsync(); await mgr.RefreshOnlineStateAsync(forceRefresh: true);
source = (await store.Sources.GetAllAsync()).Single(); source = (await store.Sources.GetAllAsync()).Single();
Assert.Equal(SourceStatus.Online, source.Status); Assert.Equal(SourceStatus.Online, source.Status);
Assert.True(source.IsIndexed); Assert.True(source.IsIndexed);
@@ -215,7 +215,7 @@ public class SourceManagerTests
Assert.Equal(guid, (await store.Sources.GetAllAsync()).Single().VolumeGuid); Assert.Equal(guid, (await store.Sources.GetAllAsync()).Single().VolumeGuid);
volumes.Online = []; volumes.Online = [];
await mgr.RefreshOnlineStateAsync(); await mgr.RefreshOnlineStateAsync(forceRefresh: true);
Assert.True(await mgr.ForgetDisconnectedAsync(@"E:\")); Assert.True(await mgr.ForgetDisconnectedAsync(@"E:\"));
Assert.Empty(await store.Sources.GetAllAsync()); Assert.Empty(await store.Sources.GetAllAsync());
@@ -231,7 +231,7 @@ public class SourceManagerTests
CapacityBytes = 64 CapacityBytes = 64
} }
]; ];
await mgr.RefreshOnlineStateAsync(); await mgr.RefreshOnlineStateAsync(forceRefresh: true);
var restored = Assert.Single(await store.Sources.GetAllAsync()); var restored = Assert.Single(await store.Sources.GetAllAsync());
Assert.Equal(guid, restored.VolumeGuid); Assert.Equal(guid, restored.VolumeGuid);
Assert.Equal(@"F:\", restored.LastRootPath); Assert.Equal(@"F:\", restored.LastRootPath);
@@ -269,12 +269,40 @@ public class SourceManagerTests
Assert.Empty(await mgr.ListUntrackedOnlineVolumesAsync()); Assert.Empty(await mgr.ListUntrackedOnlineVolumesAsync());
Assert.Equal(imported.Id, (await store.Sources.GetAllAsync()).Single().Id); Assert.Equal(imported.Id, (await store.Sources.GetAllAsync()).Single().Id);
} }
[Fact]
public async Task Cached_refresh_skips_a_second_pass_until_forced()
{
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
var volumes = new FakeVolumes
{
Online =
[
new VolumeFingerprint { Kind = SourceKind.NtfsLocal, RootPath = @"C:\", DisplayName = "C:" }
]
};
var env = new FakeEnv(Path.GetDirectoryName(db)!);
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
await mgr.InitializeAsync();
var afterInit = volumes.EnumerateCalls;
await mgr.RefreshOnlineStateAsync();
Assert.Equal(afterInit, volumes.EnumerateCalls);
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
Assert.Equal(afterInit + 1, volumes.EnumerateCalls);
}
} }
file sealed class FakeVolumes : IVolumeService file sealed class FakeVolumes : IVolumeService
{ {
public List<VolumeFingerprint> Online { get; set; } = []; public List<VolumeFingerprint> Online { get; set; } = [];
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => Online; public int EnumerateCalls;
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes()
{
Interlocked.Increment(ref EnumerateCalls);
return Online;
}
public VolumeFingerprint? Probe(string path) public VolumeFingerprint? Probe(string path)
{ {
var root = Path.GetPathRoot(path)?.TrimEnd('\\'); var root = Path.GetPathRoot(path)?.TrimEnd('\\');

View File

@@ -438,3 +438,88 @@ public class FileClassifierTests
private static FileCategory Classify(string name, bool isDirectory = false, bool isRepoRoot = false) private static FileCategory Classify(string name, bool isDirectory = false, bool isRepoRoot = false)
=> FileClassifier.Classify(name, @"C:\Downloads\" + name, isDirectory, isDirectory ? AttributeFlags.Directory : 0, isRepoRoot).Category; => FileClassifier.Classify(name, @"C:\Downloads\" + name, isDirectory, isDirectory ? AttributeFlags.Directory : 0, isRepoRoot).Category;
} }
public class GitChangeTests
{
[Fact]
public void Rename_and_delete_labels()
{
var renamed = new GitChange
{
Path = "new.txt",
OriginalPath = "old.txt",
Index = 'R',
WorkTree = '.',
State = GitChangeState.Staged
};
Assert.Equal("old.txt → new.txt", renamed.DisplayPath);
Assert.Equal("renamed", renamed.ChangeLabel);
Assert.Equal(@"C:\src\new.txt", renamed.ToFullPath(@"C:\src"));
Assert.False(renamed.IsDeleted);
var deleted = new GitChange
{
Path = "gone.txt",
Index = '.',
WorkTree = 'D',
State = GitChangeState.Unstaged
};
Assert.True(deleted.IsDeleted);
Assert.Equal("deleted", deleted.ChangeLabel);
}
}
public class BoundedWaitTests
{
[Fact]
public void Try_returns_the_result_when_work_finishes()
=> Assert.True(BoundedWait.Try(() => true, TimeSpan.FromSeconds(1)));
[Fact]
public void Try_returns_false_when_work_exceeds_the_timeout()
=> Assert.False(BoundedWait.Try(
() =>
{
Thread.Sleep(250);
return true;
},
TimeSpan.FromMilliseconds(30)));
}
public class GitCommandResultTests
{
[Fact]
public void Display_prefers_summary_then_stderr_on_failure()
{
Assert.Equal("Committed.", GitCommandResult.Ok("Committed.").DisplayMessage);
Assert.Equal(
"not a fast-forward",
new GitCommandResult
{
Succeeded = false,
ExitCode = 1,
StandardError = "not a fast-forward\n"
}.DisplayMessage);
Assert.Equal("git failed (exit 128).", new GitCommandResult
{
Succeeded = false,
ExitCode = 128
}.DisplayMessage);
}
}
public class GitStatusBadgeTests
{
[Fact]
public void Includes_merging_instead_of_clean()
{
var status = new GitStatus
{
RepoRoot = @"C:\src",
Branch = "main",
Operation = GitOperationKind.Merge
};
Assert.Equal("main · merging", status.Badge);
Assert.Equal("merging", status.OperationLabel);
}
}