Editor/widgets/CommitWidget.cs
#nullable enable
using System;
using System.Threading;
using Editor;
using Sandbox.Diagnostics;
using Sandbox.git;
using Sandbox.git.models;
namespace Sandbox.widgets;
public class CommitWidget : Widget {
const float RowHeight = 28f;
private readonly GitStore _store;
private readonly SynchronizationContext? _uiContext;
private readonly CommitMessageLineEdit _messageField;
private readonly CommitOptionsDropdown _optionsDropdown;
private readonly Button _commitButton;
private bool _isCommitting;
private bool _skipCommitHooks;
private static readonly Logger Logger = new Logger("SandGit[CommitWidget]");
public CommitWidget(Widget parent, GitStore store) : base(parent) {
_store = store ?? throw new ArgumentNullException(nameof(store));
_uiContext = SynchronizationContext.Current;
Layout = Layout.Column();
Layout.Spacing = 4f;
var row = new Widget(this) { Layout = Layout.Row() };
row.Layout.Spacing = 4f;
_messageField = new CommitMessageLineEdit(row) {
MinimumHeight = RowHeight, MinimumWidth = 0, PlaceholderText = "Commit message"
};
_messageField.TextChanged += _ => UpdateCommitButtonState();
_messageField.CommitRequested += OnCommitClicked;
_optionsDropdown = new CommitOptionsDropdown(row);
_optionsDropdown.Clicked += OnOptionsClicked;
_commitButton = new Button(row) { Text = "Commit All" };
_commitButton.Clicked += OnCommitClicked;
_store.OnDataChanged += UpdateCommitButtonState;
UpdateCommitButtonState();
row.Layout.Add(_messageField, 1);
row.Layout.Add(_commitButton);
row.Layout.Add(_optionsDropdown);
Layout.Add(row);
}
protected override void OnClosed() {
_store.OnDataChanged -= UpdateCommitButtonState;
base.OnClosed();
}
void UpdateCommitButtonState() {
if ( !IsValid )
return;
var hasMessage = !string.IsNullOrWhiteSpace(_messageField.Text);
var canCommit = CanCommit(out var reasonDisabled);
_commitButton.Enabled = hasMessage && canCommit && !_isCommitting;
_commitButton.Text = _isCommitting ? "Committing…" : "Commit All";
_commitButton.ToolTip = GetCommitButtonToolTip(_isCommitting, hasMessage, canCommit, reasonDisabled);
}
static string GetCommitButtonToolTip(bool isCommitting, bool hasMessage, bool canCommit, string? reasonDisabled) {
if ( isCommitting )
return "Committing…";
if ( !hasMessage )
return "Commit message required";
if ( !canCommit )
return reasonDisabled ?? "Commit all staged and unstaged changes";
return "Commit all staged and unstaged changes";
}
bool CanCommit(out string? reasonDisabled) {
reasonDisabled = null;
if ( _store.RepositoryType is not RegularRepositoryType ) {
reasonDisabled = _store.IsLoading ? "Loading…" : "Not a git repository.";
return false;
}
var repo = _store.CurrentRepository;
if ( repo == null ) {
reasonDisabled = "No repository.";
return false;
}
var files = _store.FullStatus?.WorkingDirectory.Files;
var changeCount = files?.Count ?? 0;
if ( changeCount == 0 ) {
reasonDisabled = "No changes to commit.";
return false;
}
return true;
}
void OnOptionsClicked() {
var menu = new ContextMenu();
var label = _skipCommitHooks ? "Bypass Commit Hooks ✓" : "Bypass Commit Hooks";
menu.AddOption(label, null, () => {
_skipCommitHooks = !_skipCommitHooks;
});
menu.OpenAtCursor();
}
async void OnCommitClicked() {
var message = _messageField.Text?.Trim() ?? "";
if ( string.IsNullOrWhiteSpace(message) )
return;
if ( !CanCommit(out _) )
return;
var repo = _store.CurrentRepository!;
_isCommitting = true;
UpdateCommitButtonState();
try {
// files: null => "Commit All" (stage everything via add -A). Desktop can commit selected files only.
_ = await Sandbox.git.Commit
.CreateCommitAsync(repo, message, files: null, amend: false, noVerify: _skipCommitHooks)
.ConfigureAwait(false);
_uiContext?.Post(_ => {
if ( !IsValid ) return;
_messageField.Text = "";
_isCommitting = false;
UpdateCommitButtonState();
_store.RequestDebouncedRefresh("commit");
}, null);
} catch ( Exception ex ) {
Logger.Warning($"Commit failed: {ex.Message}");
_uiContext?.Post(_ => {
if ( !IsValid ) return;
_isCommitting = false;
UpdateCommitButtonState();
}, null);
}
}
// ─── Not implemented yet (Desktop parity) ───────────────────────────────
// • Repo rules: branch protection, signed commits, commit message patterns; getButtonTooltip() for disabled reasons.
// • Select files to commit: anyFilesSelected, filesToBeCommittedCount; we currently only support "Commit All".
}
/// <summary>Line edit for the commit message; hosts Cmd/Ctrl+Enter shortcut only when this field is focused.</summary>
sealed class CommitMessageLineEdit : LineEdit {
public event Action? CommitRequested;
public CommitMessageLineEdit(Widget parent) : base(parent) { }
[Shortcut("sandgit.commit-submit", "CTRL+ENTER", typeof(CommitMessageLineEdit), ShortcutType.Widget)]
private void OnCommitShortcut() {
CommitRequested?.Invoke();
}
}
sealed class CommitOptionsDropdown : Button {
public CommitOptionsDropdown(Widget parent) : base(parent) {
FixedWidth = 12f;
MinimumHeight = 24f;
ToolTip = "Commit options";
}
protected override void OnPaint() {
WidgetPaintUtils.DrawDropdownChevron(new Rect(0, Size));
}
}