Editor/widgets/RepositoryStatusWidget.cs
using System;
using System.IO;
using Editor;
using Sandbox.Diagnostics;
using Sandbox.git;
using Sandbox.git.models;
namespace Sandbox.widgets;
public class RepositoryStatusWidget : Widget {
const float StatusButtonSize = 28f;
private readonly GitStore _store;
private readonly Action _onOpenSettings;
private readonly ConnectionStatusButton _statusButton;
private readonly RepoNameField _repoNameField;
private readonly RepoDropdownIndicator _dropdownIndicator;
private static readonly Logger Logger = new Logger("SandGit[RepoWidget]");
public RepositoryStatusWidget(Widget parent, GitStore store, Action onOpenSettings = null) : base(parent) {
_store = store ?? throw new ArgumentNullException(nameof(store));
_onOpenSettings = onOpenSettings;
Layout = Layout.Column();
Layout.Spacing = 6f;
var row = new Widget(this) { Layout = Layout.Row() };
row.Layout.Spacing = 4f;
_statusButton = new ConnectionStatusButton(row, _store) {
FixedWidth = StatusButtonSize, FixedHeight = StatusButtonSize
};
_repoNameField = new RepoNameField(row);
_dropdownIndicator = new RepoDropdownIndicator(row) { Icon = null };
_dropdownIndicator.Clicked += OnDropdownClicked;
row.Layout.Add(_statusButton);
row.Layout.Add(_repoNameField, 1);
row.Layout.Add(_dropdownIndicator);
Layout.Add(row);
_store.OnDataChanged += UpdateFromStore;
UpdateFromStore();
}
protected override void OnClosed() {
_store.OnDataChanged -= UpdateFromStore;
base.OnClosed();
}
void OnDropdownClicked() {
var menu = new ContextMenu(null);
if ( _store.RepositoryType is MissingRepositoryType )
menu.AddOption("Create Repo", "add", OnCreateRepoClicked);
menu.AddOption("Refresh", "refresh", () => _store.RequestDebouncedRefresh("menu refresh"));
menu.AddOption("Settings", "settings", OnSettingsClicked);
menu.OpenAtCursor();
}
void OnSettingsClicked() {
_onOpenSettings?.Invoke();
}
async void OnCreateRepoClicked() {
var rootPath = _store.RootPath;
if ( string.IsNullOrEmpty(rootPath) ) return;
_repoNameField.Text = "Creating repository…";
var fullPath = rootPath;
try {
Directory.CreateDirectory(fullPath);
} catch ( UnauthorizedAccessException ex ) {
Logger.Trace($"Create repo: access denied at {fullPath}: {ex.Message}");
ShowCreateError("You may not have permission to create a directory here.");
return;
} catch ( Exception ex ) when
( ex is ArgumentException or PathTooLongException or DirectoryNotFoundException ) {
Logger.Trace($"Create repo: invalid path {fullPath}: {ex.Message}");
ShowCreateError("The path is invalid or could not be created.");
return;
}
try {
await InitRepository.InitGitRepositoryAsync(fullPath).ConfigureAwait(true);
} catch ( GitException ex ) {
Logger.Trace($"Create repo: init failed at {fullPath}: {ex.Message}");
ShowCreateError($"Git init failed: {ex.Result.Stderr.Trim()}");
return;
}
_store.RequestDebouncedRefresh("create repo");
}
void ShowCreateError(string message) {
_repoNameField.Text = message;
}
void UpdateFromStore() {
if ( !IsValid )
return;
if ( _repoNameField == null )
return;
Update();
if ( _statusButton != null ) {
_statusButton.ToolTip = BuildConnectionToolTip();
_statusButton.Update();
}
if ( _store.IsLoading ) {
_repoNameField.Text = "Checking for repository…";
return;
}
var repoType = _store.RepositoryType;
if ( repoType == null ) {
_repoNameField.Text = "Checking for repository…";
return;
}
if ( repoType is MissingRepositoryType ) {
_repoNameField.Text = "No repository found";
return;
}
_repoNameField.Text = GetDisplayRepoName(repoType);
}
string BuildConnectionToolTip() {
var repoType = _store.RepositoryType;
var path = repoType != null ? GetDisplayPath(repoType) : null;
if ( string.IsNullOrEmpty(path) || path == "(bare)" )
path = _store.RootPath;
if ( string.IsNullOrEmpty(path) )
path = "(none)";
var status = _store.IsLoading ? "pending" : (repoType is MissingRepositoryType ? "missing" : "active");
return $"{path}\nConnection {status}";
}
static string GetDisplayRepoName(RepositoryType repoType) {
var fullPath = GetDisplayPath(repoType);
if ( string.IsNullOrEmpty(fullPath) ) return string.Empty;
if ( fullPath == "(bare)" ) return fullPath;
return GetRepoNameFromPath(fullPath);
}
static string GetRepoNameFromPath(string repoPath) {
if ( string.IsNullOrEmpty(repoPath) ) return string.Empty;
var trimmed = repoPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var name = Path.GetFileName(trimmed);
return string.IsNullOrEmpty(name) ? repoPath : name;
}
static string GetDisplayPath(RepositoryType repoType) {
if ( repoType is RegularRepositoryType regular )
return regular.TopLevelWorkingDirectory;
if ( repoType is UnsafeRepositoryType unsafeRepo )
return unsafeRepo.RepositoryPath;
if ( repoType is BareRepositoryType )
return "(bare)";
return string.Empty;
}
}
sealed class ConnectionStatusButton : Widget {
readonly GitStore _store;
const int SignalIconSize = 14;
const float BarWidth = 2.5f;
const float BarGap = 1.5f;
const float BarCornerRadius = 1f;
/// <summary>Heights for the 3 bars (short, medium, tall), bottom-aligned.</summary>
static readonly float[] BarHeights = { 4f, 7f, 10f };
public ConnectionStatusButton(Widget parent, GitStore store) : base(parent) {
_store = store ?? throw new ArgumentNullException(nameof(store));
MinimumSize = 24;
}
protected override void OnPaint() {
var r = new Rect(0, Size);
var (bars, iconColor) = GetSignalState();
var bgColor = iconColor.Darken(0.7f).Desaturate(0.5f);
Paint.ClearPen();
Paint.SetBrush(in bgColor);
Paint.DrawRect(r, 4f);
var iconRect = WidgetPaintUtils.GetCenteredIconRect(r, SignalIconSize, 6, 6, 6, 6);
DrawSignalBars(iconRect, bars, iconColor);
}
/// <summary>Returns (activeBarCount 1–3, color). 1 bar red = not detected, 2 bars orange = loading, 3 bars green = ready.</summary>
(int bars, Color color) GetSignalState() {
var repoType = _store.RepositoryType;
if ( repoType == null || repoType is MissingRepositoryType )
return (1, Theme.Red);
if ( _store.IsLoading || _store.IsLoadingHistory )
return (2, Theme.Yellow); // orange-like: loading
return (3, Theme.Green);
}
void DrawSignalBars(Rect iconRect, int activeBars, Color color) {
Paint.SetPen(in color);
Paint.SetBrush(in color);
var totalWidth = 3 * BarWidth + 2 * BarGap;
var left = iconRect.Left + (iconRect.Width - totalWidth) * 0.5f;
var bottom = iconRect.Bottom - 2f;
for ( var i = 0; i < 3; i++ ) {
var x = left + i * (BarWidth + BarGap);
var height = BarHeights[i];
var y = bottom - height;
if ( i < activeBars ) {
Paint.DrawRect(new Rect(x, y, BarWidth, height), BarCornerRadius);
}
}
}
}
sealed class RepoDropdownIndicator : Button {
public RepoDropdownIndicator(Widget parent) : base(parent) {
FixedWidth = 12f;
MinimumHeight = 24f;
ToolTip = "Repository options";
}
protected override void OnPaint() {
WidgetPaintUtils.DrawDropdownChevron(new Rect(0, Size));
}
}
sealed class RepoNameField : Widget {
string _text = "";
const float Padding = 8f;
public string Text {
get => _text;
set {
if ( _text == value ) return;
_text = value ?? "";
Update();
}
}
public RepoNameField(Widget parent) : base(parent) {
MinimumHeight = 28f;
MinimumWidth = 0;
}
protected override void OnPaint() {
var r = new Rect(0, Size);
WidgetPaintUtils.DrawTextFieldBackground(r);
var textRect = r.Shrink(Padding, 4f, Padding, 4f);
if ( string.IsNullOrEmpty(_text) )
return;
Paint.SetDefaultFont();
Paint.SetPen(Theme.Text);
Paint.DrawText(textRect, _text, TextFlag.LeftCenter);
}
}