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:
@@ -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 = "";
|
||||
|
||||
367
src/Explorer.Presentation/ViewModels/GitChangesViewModel.cs
Normal file
367
src/Explorer.Presentation/ViewModels/GitChangesViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
151
src/Explorer.Presentation/ViewModels/GitCommitViewModel.cs
Normal file
151
src/Explorer.Presentation/ViewModels/GitCommitViewModel.cs
Normal 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);
|
||||
}
|
||||
21
src/Explorer.Presentation/ViewModels/GitDiffViewModel.cs
Normal file
21
src/Explorer.Presentation/ViewModels/GitDiffViewModel.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user