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

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

View File

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

View File

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

View File

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

View File

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

View File

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