Editor/widgets/HistoryWidget.cs
#nullable enable
using System;
using System.Collections.Generic;
using Editor;
using Sandbox.git;
using Sandbox.git.models;
using CommitModel = Sandbox.git.models.Commit;
namespace Sandbox.widgets;
static class HistoryWidgetHelpers {
/// <summary>Format date like Desktop: relative ("2 hours ago", "3 days ago") or absolute if older.</summary>
public static string FormatCommitDate(DateTimeOffset date) {
var now = DateTimeOffset.Now;
var diff = now - date;
if ( diff.TotalMinutes < 1 ) return "just now";
if ( diff.TotalMinutes < 60 ) return $"{(int)diff.TotalMinutes} minutes ago";
if ( diff.TotalHours < 24 ) return $"{(int)diff.TotalHours} hours ago";
if ( diff.TotalDays < 7 ) return $"{(int)diff.TotalDays} days ago";
return date.ToString("yyyy-MM-dd");
}
public static bool LooksLikeSha(string value) {
if ( string.IsNullOrEmpty(value) || value.Length != 40 ) return false;
for ( var i = 0; i < value.Length; i++ ) {
var c = value[i];
if ( (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') )
continue;
return false;
}
return true;
}
}
/// <summary>
/// Simplified git history: commit summary, date, author, and branch ↑/↓ (ahead/behind).
/// Loads history when the widget is shown (mirrors Desktop loadCommitBatch on tab switch).
/// </summary>
public class HistoryWidget : Widget {
const float MinContentHeight = 120f;
readonly GitStore _store;
readonly ScrollArea _scroller;
bool _initialLoadTriggered;
public HistoryWidget(Widget parent, GitStore store) : base(parent) {
_store = store ?? throw new ArgumentNullException(nameof(store));
Layout = Layout.Column();
Layout.Spacing = 4f;
_scroller = new ScrollArea(this);
_scroller.MinimumHeight = MinContentHeight;
Layout.Add(_scroller, 1);
_scroller.Canvas = new Widget(_scroller);
_scroller.Canvas.Layout = Layout.Column();
_scroller.Canvas.Layout.AddStretchCell();
_store.OnDataChanged += UpdateFromStore;
UpdateFromStore();
}
protected override void OnClosed() {
_store.OnDataChanged -= UpdateFromStore;
base.OnClosed();
}
/// <summary>
/// Call when the History tab is shown to load history if needed (connect from tab switch).
/// </summary>
public void EnsureHistoryLoaded() {
if ( !IsValid || !Visible )
return;
if ( _store.CurrentRepository == null )
return;
if ( _store.IsLoadingHistory )
return;
var history = _store.History;
if ( history.Count > 0 )
return;
if ( _initialLoadTriggered )
return;
_initialLoadTriggered = true;
_ = _store.LoadCommitBatchAsync("HEAD", 0);
}
void UpdateFromStore() {
if ( !IsValid )
return;
if ( _store.CurrentRepository == null )
_initialLoadTriggered = false;
if ( Visible && _store.CurrentRepository != null && _store.History.Count == 0 && !_store.IsLoadingHistory )
EnsureHistoryLoaded();
if ( _store.IsLoading || _store.RepositoryType is not RegularRepositoryType ) {
RebuildRows(null);
return;
}
var fullStatus = _store.FullStatus;
var ab = fullStatus?.BranchAheadBehind;
var upDown = ab != null && (ab.Ahead > 0 || ab.Behind > 0)
? $"↑{ab.Ahead} ↓{ab.Behind}"
: "";
RebuildRows(_store.History, _store.CommitLookup);
}
void RebuildRows(IReadOnlyList<string>? history, IReadOnlyDictionary<string, CommitModel>? lookup = null) {
var canvas = new Widget(_scroller);
canvas.Layout = Layout.Column();
lookup ??= _store.CommitLookup;
if ( _store.IsLoadingHistory && (history == null || history.Count == 0) ) {
var loadingRow = new Label("Loading history…", canvas);
canvas.Layout.Add(loadingRow);
} else if ( history != null && history.Count > 0 ) {
for ( var i = 0; i < history.Count; i++ ) {
var sha = history[i];
if ( !lookup.TryGetValue(sha, out var commit) )
continue;
var row = new HistoryRow(commit) { Index = i };
canvas.Layout.Add(row);
}
var loadMore = new Button(canvas) { Text = "Load more…" };
loadMore.Clicked += OnLoadMoreClicked;
canvas.Layout.Add(loadMore);
}
canvas.Layout.AddStretchCell();
_scroller.Canvas = canvas;
}
void OnLoadMoreClicked() {
if ( _store.CurrentRepository == null || _store.IsLoadingHistory )
return;
var history = _store.History;
_ = _store.LoadCommitBatchAsync("HEAD", history.Count);
}
}
/// <summary>Single row: commit summary, date, author. Text is truncated to fit the row width.</summary>
public class HistoryRow : Frame {
const float RowHeight = 36f;
const float HorizontalPadding = 10f;
const float VerticalPadding = 6f;
const float LineGap = 2f;
const int MaxSummaryChars = 32;
const int MaxAuthorChars = 18;
const int ShortShaDisplayLength = 7;
readonly CommitModel _commit;
public int Index { get; set; }
public HistoryRow(CommitModel commit) : base(null) {
_commit = commit;
MinimumSize = (int)RowHeight;
Cursor = CursorShape.Finger;
}
static string Truncate(string value, int maxChars, bool addEllipsis = true) {
if ( string.IsNullOrEmpty(value) || value.Length <= maxChars )
return value ?? "";
return value.Substring(0, addEllipsis ? Math.Max(0, maxChars - 1) : maxChars) + (addEllipsis ? "…" : "");
}
protected override void OnPaint() {
var r = new Rect(0, Size);
var borderColor = Theme.Text.Darken(0.6f).Desaturate(0.5f);
var textColor = Theme.Text;
var textColorSecondary = Theme.Text.Darken(0.25f);
Paint.ClearPen();
Paint.SetBrush(borderColor);
Paint.DrawRect(new Rect(r.Left, r.Bottom - 1f, r.Width, 1f));
var contentWidth = r.Width - 2f * HorizontalPadding;
if ( contentWidth <= 0 )
return;
var left = r.Left + HorizontalPadding;
var innerHeight = r.Height - 2f * VerticalPadding;
if ( innerHeight <= 0 )
return;
var lineHeight = (innerHeight - LineGap) * 0.5f;
var line1Rect = new Rect(left, r.Top + VerticalPadding, contentWidth, lineHeight);
var line2Rect = new Rect(left, r.Top + VerticalPadding + lineHeight + LineGap, contentWidth, lineHeight);
Paint.SetPen(textColor);
var shortSha = _commit.ShortSha ?? _commit.Sha ?? "";
if ( shortSha.Length > ShortShaDisplayLength )
shortSha = shortSha.Substring(0, ShortShaDisplayLength);
var summary = string.IsNullOrEmpty(_commit.Summary) ? "" : _commit.Summary;
summary = Truncate(summary, MaxSummaryChars);
var line1 = string.IsNullOrEmpty(summary) ? shortSha : shortSha + " " + summary;
line1 = Truncate(line1, Math.Max(1, (int)(contentWidth / 6f)));
Paint.DrawText(line1Rect, line1, TextFlag.LeftCenter);
var dateStr = _commit.Author?.Date != null
? HistoryWidgetHelpers.FormatCommitDate(_commit.Author.Date)
: "—";
var rawAuthor = _commit.Author?.Name ?? "—";
var authorStr = HistoryWidgetHelpers.LooksLikeSha(rawAuthor) ? "—" : Truncate(rawAuthor, MaxAuthorChars);
var line2 = dateStr + " " + authorStr;
line2 = Truncate(line2, Math.Max(1, (int)(contentWidth / 6f)));
Paint.SetPen(textColorSecondary);
Paint.DrawText(line2Rect, line2, TextFlag.LeftCenter);
}
}