Editor/git/GitStore.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox.Diagnostics;
using Sandbox.git.models;
namespace Sandbox.git;
using GitBranch = models.Branch;
/// <summary>
/// Centralized git data for the current project. Owns one coordinated refresh and notifies widgets via OnDataChanged.
/// Subscribes to editor events and debounces refresh requests.
/// History loading mirrors Desktop's loadCommitBatch (getCommits + storeCommits).
/// </summary>
public class GitStore {
const int RecentBranchesLimit = 10;
const int DebounceDelayMs = 400;
/// <summary>Number of commits to load per history batch (mirrors Desktop CommitBatchSize).</summary>
public const int CommitBatchSize = 100;
readonly string _rootPath;
readonly SynchronizationContext _uiContext;
readonly object _dataLock = new();
CancellationTokenSource? _debounceCts;
readonly object _refreshLock = new();
bool _refreshInProgress;
bool _refreshPending;
private static readonly Logger Logger = new Logger("GitStore");
RepositoryType? _repositoryType;
FullStatusResult? _fullStatus;
IReadOnlyList<GitBranch>? _branches;
IReadOnlyList<string>? _recentBranchNames;
Repository? _currentRepository;
readonly Dictionary<string, models.Commit> _commitLookup = new();
readonly List<string> _history = new();
readonly object _historyLock = new();
bool _historyLoadInProgress;
bool _historyLoadPending;
public GitStore(string rootPath, SynchronizationContext? uiContext = null) {
_rootPath = rootPath ?? string.Empty;
_uiContext = uiContext ?? SynchronizationContext.Current!;
}
public string RootPath => _rootPath;
public bool IsLoading { get; private set; }
public RepositoryType? RepositoryType {
get {
lock ( _dataLock ) return _repositoryType;
}
private set {
lock ( _dataLock ) _repositoryType = value;
}
}
public FullStatusResult? FullStatus {
get {
lock ( _dataLock ) return _fullStatus;
}
private set {
lock ( _dataLock ) _fullStatus = value;
}
}
public IReadOnlyList<GitBranch>? Branches {
get {
lock ( _dataLock ) return _branches;
}
private set {
lock ( _dataLock ) _branches = value;
}
}
public IReadOnlyList<string>? RecentBranchNames {
get {
lock ( _dataLock ) return _recentBranchNames;
}
private set {
lock ( _dataLock ) _recentBranchNames = value;
}
}
/// <summary>Current repository when RepositoryType is a regular repo; null otherwise.</summary>
public Repository? CurrentRepository {
get {
lock ( _dataLock ) return _currentRepository;
}
private set {
lock ( _dataLock ) _currentRepository = value;
}
}
/// <summary>Commits keyed by SHA (populated when history is loaded).</summary>
public IReadOnlyDictionary<string, models.Commit> CommitLookup {
get {
lock ( _historyLock ) {
return new Dictionary<string, models.Commit>(_commitLookup);
}
}
}
/// <summary>Ordered list of commit SHAs (HEAD first). Empty until history is loaded.</summary>
public IReadOnlyList<string> History {
get {
lock ( _historyLock ) {
return _history.Count == 0 ? Array.Empty<string>() : _history.ToList();
}
}
}
/// <summary>True while a history batch load is in progress.</summary>
public bool IsLoadingHistory {
get {
lock ( _historyLock ) return _historyLoadInProgress;
}
}
/// <summary>Raised on the UI thread after cached data is updated.</summary>
public event Action? OnDataChanged;
/// <summary>
/// Load a batch of commits from the repository (mirrors Desktop loadCommitBatch).
/// Uses HEAD or the given commitish, with optional skip for pagination.
/// Stores commits in CommitLookup and appends/prepends SHAs to History; raises OnDataChanged on the UI thread.
/// </summary>
/// <param name="commitish">Starting point (e.g. HEAD). If null, uses HEAD.</param>
/// <param name="skip">Number of commits to skip for pagination. 0 = first batch (replaces history).</param>
/// <returns>SHAs of the commits loaded, or null if load was skipped or failed.</returns>
public async Task<IReadOnlyList<string>?> LoadCommitBatchAsync(string? commitish = null, int skip = 0) {
var repo = CurrentRepository;
if ( repo == null )
return null;
lock ( _historyLock ) {
if ( _historyLoadInProgress ) {
_historyLoadPending = true;
return null;
}
_historyLoadInProgress = true;
}
try {
var commits = await Log.GetCommitsAsync(repo, commitish ?? "HEAD", CommitBatchSize, skip)
.ConfigureAwait(false);
if ( commits.Count == 0 ) {
_uiContext.Post(_ => {
lock ( _historyLock ) {
_historyLoadInProgress = false;
if ( _historyLoadPending ) {
_historyLoadPending = false;
_ = LoadCommitBatchAsync(commitish, skip);
}
}
OnDataChanged?.Invoke();
}, null);
return Array.Empty<string>();
}
var newShas = commits.Select(c => c.Sha).ToList();
_uiContext.Post(_ => {
lock ( _historyLock ) {
foreach ( var c in commits )
_commitLookup[c.Sha] = c;
if ( skip == 0 )
_history.Clear();
_history.AddRange(newShas);
_historyLoadInProgress = false;
if ( _historyLoadPending ) {
_historyLoadPending = false;
_ = LoadCommitBatchAsync(commitish, _history.Count);
}
}
OnDataChanged?.Invoke();
}, null);
return newShas;
} catch ( Exception ex ) {
Logger.Trace($"[GitStore] LoadCommitBatchAsync failed: {ex.Message}");
_uiContext.Post(_ => {
lock ( _historyLock ) {
_historyLoadInProgress = false;
_historyLoadPending = false;
}
OnDataChanged?.Invoke();
}, null);
return null;
}
}
/// <summary>Clear cached history and commit lookup (e.g. when repo changes).</summary>
public void ClearHistory() {
lock ( _historyLock ) {
_commitLookup.Clear();
_history.Clear();
}
_uiContext.Post(_ => OnDataChanged?.Invoke(), null);
}
public void RequestDebouncedRefresh(string? triggeredByEvent = null) {
Logger.Trace(
$"[GitStore] RequestDebouncedRefresh triggered by event: {triggeredByEvent ?? "(direct call)"}");
_debounceCts?.Cancel();
_debounceCts = new CancellationTokenSource();
var cts = _debounceCts;
var eventName = triggeredByEvent;
_ = Task.Delay(DebounceDelayMs, cts.Token).ContinueWith(t => {
if ( t.IsCanceled || cts.IsCancellationRequested )
return;
ScheduleRefreshAfterDebounce(eventName);
}, TaskScheduler.Default);
}
public async Task RefreshAsync() {
Logger.Trace("[GitStore] RefreshAsync started");
try {
var repoType = await RevParse.GetRepositoryTypeAsync(_rootPath).ConfigureAwait(false);
if ( repoType is not RegularRepositoryType regular ) {
ClearHistory();
PostUpdateData(repoType, null, null, null, null);
return;
}
var repoPath = regular.TopLevelWorkingDirectory;
var repository = new Repository(repoPath, 0, null, false);
var fullStatusTask = Status.GetFullStatusAsync(repoPath);
var branchesTask = ForEachRef.GetBranchesAsync(repository);
var recentTask = Reflog.GetRecentBranchesAsync(repository, RecentBranchesLimit + 1);
await Task.WhenAll(fullStatusTask, branchesTask, recentTask).ConfigureAwait(false);
var fullStatus = await fullStatusTask.ConfigureAwait(false);
var branches = await branchesTask.ConfigureAwait(false);
var recentRaw = await recentTask.ConfigureAwait(false);
var recent = recentRaw?.Count > 0
? new List<string>(recentRaw).GetRange(0, Math.Min(RecentBranchesLimit, recentRaw.Count))
: (IReadOnlyList<string>?)new List<string>();
PostUpdateData(repoType, fullStatus, branches, recent, repository);
} catch ( Exception ex ) {
Logger.Trace($"[GitStore] RefreshAsync failed: {ex.Message}");
ClearHistory();
PostUpdateData(RepositoryType, null, null, null, null);
} finally {
Logger.Trace("[GitStore] RefreshAsync completed");
}
}
void ScheduleRefreshAfterDebounce(string? triggeredByEvent) {
lock ( _refreshLock ) {
if ( _refreshInProgress ) {
_refreshPending = true;
return;
}
_refreshInProgress = true;
}
_ = RunRefreshLoopAsync(triggeredByEvent);
}
async Task RunRefreshLoopAsync(string? triggeredByEvent) {
string? eventNameForLog = triggeredByEvent;
while ( true ) {
Logger.Trace(
$"[GitStore] Debounce elapsed, running RefreshAsync (was triggered by: {eventNameForLog ?? "(direct)"})");
_uiContext.Post(_ => {
IsLoading = true;
OnDataChanged?.Invoke();
}, null);
await RefreshAsync();
lock ( _refreshLock ) {
if ( !_refreshPending ) {
_refreshInProgress = false;
return;
}
_refreshPending = false;
}
eventNameForLog = null;
}
}
void PostUpdateData(
RepositoryType? repoType,
FullStatusResult? fullStatus,
IReadOnlyList<GitBranch>? branches,
IReadOnlyList<string>? recentBranchNames,
Repository? currentRepository
) {
_uiContext.Post(_ => {
IsLoading = false;
RepositoryType = repoType;
FullStatus = fullStatus;
Branches = branches;
RecentBranchNames = recentBranchNames;
CurrentRepository = currentRepository;
OnDataChanged?.Invoke();
}, null);
}
// ─── Editor events ───────────────────────────────────────────────────────
// Only subscribe to events that do not re-fire when our widgets update.
// scene.session.save is NOT subscribed: it fires when session state changes and is re-raised
// after our OnDataChanged UI updates, causing an infinite refresh loop.
[Event("scene.saved", Priority = 100)]
public void OnSceneSaved(Scene _) => RequestDebouncedRefresh("scene.saved");
[Event("assetsystem.newfolder", Priority = 100)]
public void OnAssetsystemNewfolder() => RequestDebouncedRefresh("assetsystem.newfolder");
[Event("actiongraph.saved", Priority = 100)]
public void OnActiongraphSaved(object _) => RequestDebouncedRefresh("actiongraph.saved");
[Event("hotloaded", Priority = 100)]
public void OnHotloaded() => RequestDebouncedRefresh("hotloaded");
}