Editor window for a Network Storage sync tool. Lists local and remote endpoints, collections, and workflows, with per-item push/pull/test/diff actions, a publish target selector (Staged/Main vs Live), and revision/server status panels. It also starts background revision polling and compatibility checks.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Sandbox;
using Editor;
using static SyncToolVerification;
/// <summary>
/// Editor window for Network Storage sync operations.
/// Checks remote for newer versions, shows per-item Push/Pull buttons.
/// Push warns if remote is newer. Pull buttons only appear when remote differs.
/// After push or pull, re-checks and clears stale state.
/// </summary>
public partial class SyncToolWindow : PaintedWindow
{
private static readonly JsonSerializerOptions _readOptions = new()
{
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
private string _status = "Ready";
private bool _statusIsError;
private bool _busy;
private static int _openWindowCount;
public static bool IsWindowOpen => _openWindowCount > 0;
private string _busyItem;
private Dictionary<string, ItemState> _items = new();
private List<ClickRegion> _buttons = new();
private Vector2 _mousePos;
private bool _hasCheckedRemote;
// Generate state
private bool _generateBusy;
// Cached file lists
private string[] _endpointFiles = Array.Empty<string>();
private string[] _collectionFiles = Array.Empty<string>(); // collections/{name}.collection.yml
private string[] _workflowFiles = Array.Empty<string>(); // workflows/{id}.workflow.yml
// Remote data cache (from last check)
private JsonElement? _remoteEndpoints;
private JsonElement? _remoteCollections;
private JsonElement? _remoteWorkflows;
// ── Revision info ──
private bool _packageInfoDetected;
private string _packageIdent;
private long? _currentRevisionId;
private string _publishStatus;
private string _publishTarget = "live"; // "live" or "next"
// ── Server-side revision state (from GET /game-package) ──
private long? _serverCurrentRevisionId;
private long? _serverLatestRevisionId;
private string _serverLastSyncedAt;
private long? _serverRevisionFirstSyncedAtUnix;
private int _serverEndpointOverrideCount;
private void SetPublishTarget( string target )
{
var normalized = string.Equals( target, "next", StringComparison.OrdinalIgnoreCase )
? "next"
: "live";
if ( string.Equals( _publishTarget, normalized, StringComparison.OrdinalIgnoreCase ) )
return;
_publishTarget = normalized;
SyncToolConfig.SetPublishTarget( normalized );
// The game reads the target from the credentials file and NSConfig; regenerate so both match the selection.
TryRunCodeGeneration( "publish target change" );
_remoteEndpoints = null;
_remoteCollections = null;
_hasCheckedRemote = false;
Update();
}
private string PublishTargetLabel => _publishTarget == "next" ? "Staged/Main" : "Live";
private string PushAllLabel => _publishTarget == "next" ? "Push Staged" : "Push Live";
private string TestAllLabel => _publishTarget == "next" ? "Test Staged" : "Test Live";
// ── Scroll state ──
private float _scrollY;
private float _scrollAreaTop;
private float _contentHeight;
private const float RowH = 29f;
// ── Sync log (shown after push/pull) ──
private List<SyncLogEntry> _syncLog = new();
private struct SyncLogEntry
{
public string Name;
public string Type; // "Endpoint", "Collection", "Workflow"
public bool Ok;
public bool Warn; // Non-blocking diagnostic (preflight warning): shown in orange, not counted as pushed/failed
public string Detail; // "Pushed", "Created (new)", "Failed", "Verified ✓", "Mismatch — see diff", etc.
}
private float MaxScroll => !IsValid ? 0 : Math.Max( 0, _contentHeight - ( Height - _scrollAreaTop ) + 60 );
private struct ClickRegion
{
public Rect Rect;
public string Id;
public Action OnClick;
}
private enum SyncStatus { Unknown, InSync, LocalOnly, RemoteOnly, Differs, MergeAvailable }
private struct ItemState
{
public string SyncResult;
public bool RemoteDiffers;
public SyncStatus Status;
public string DiffSummary;
public string LocalJson;
public string RemoteJson;
// Display-only YAML rendering of LocalJson/RemoteJson, sorted by key.
// DiffViewWindow uses these so users see YAML, matching the on-disk format.
public string LocalYaml;
public string RemoteYaml;
}
public SyncToolWindow()
{
_openWindowCount++;
Title = "Network Storage Sync";
Size = new Vector2( 720, 620 );
MinimumSize = new Vector2( 550, 400 );
// Enable mouse tracking for hover effects
MouseTracking = true;
SyncToolConfig.Load();
_publishTarget = SyncToolConfig.PublishTarget;
RefreshFileList();
// Kick off package detection in the background so the revision panel
// shows immediately without waiting for "Pull from Web".
if ( SyncToolConfig.IsValid )
_ = DetectPackageInfoAsync();
_ = RefreshCompatibilityAsync();
}
private async Task RefreshCompatibilityAsync()
{
try
{
await EditorCompatibility.RefreshAsync();
Update();
if ( EditorCompatibility.TakeModal() is { } modal )
MessageDialog.Show( modal.Title, modal.Message, "" );
}
catch ( Exception ex )
{
Log.Info( $"[SyncTool] Compatibility check: {ex.Message}" );
}
}
/// <summary>One dismissible notice under the server line. Errors cannot be dismissed because they block Push All.</summary>
private void DrawCompatBanner( float pad, float w, ref float y )
{
var notice = EditorCompatibility.Banner;
if ( notice is null ) return;
var color = notice.Level == "error" ? Color.Red : notice.Level == "warning" ? Color.Orange : Color.Cyan;
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( color.WithAlpha( 0.95f ) );
foreach ( var line in EditorCompatibility.Wrap( $"{notice.Title}: {notice.Message}", w - 70 ) )
{
Paint.DrawText( new Rect( pad, y, w - 70, 14 ), line, TextFlag.LeftCenter );
y += 14;
}
if ( notice.Level != "error" )
DrawSmallButton( new Rect( pad + w - 64, y - 16, 64, 18 ), "Dismiss", Color.White, "compat_dismiss", () => { EditorCompatibility.DismissBanner(); Update(); } );
y += 6;
}
private async Task DetectPackageInfoAsync()
{
try
{
await NetworkStoragePackageInfo.DetectAsync();
_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
_packageIdent = NetworkStoragePackageInfo.PackageIdent;
_currentRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;
_publishStatus = NetworkStoragePackageInfo.PublishStatus;
_lastKnownRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;
// Sync package info to backend so server state is available immediately
if ( _packageInfoDetected )
await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );
// Fetch server-side revision state so the panel shows immediately
await RefreshServerPackageStateAsync();
Update();
}
catch ( Exception ex )
{
Log.Info( $"[SyncTool] Background package detection: {ex.Message}" );
}
}
private async Task RefreshServerPackageStateAsync()
{
try
{
var serverPkg = await SyncToolApi.GetGamePackage();
if ( serverPkg.HasValue && serverPkg.Value.TryGetProperty( "gamePackage", out var gp ) && gp.ValueKind == JsonValueKind.Object )
{
_serverCurrentRevisionId = gp.TryGetProperty( "currentRevisionId", out var cr ) && cr.ValueKind == JsonValueKind.Number ? cr.GetInt64() : null;
_serverLatestRevisionId = gp.TryGetProperty( "latestRevisionId", out var lr ) && lr.ValueKind == JsonValueKind.Number ? lr.GetInt64() : null;
_serverLastSyncedAt = gp.TryGetProperty( "lastSyncedAt", out var ls ) && ls.ValueKind == JsonValueKind.String ? ls.GetString() : null;
_serverRevisionFirstSyncedAtUnix = gp.TryGetProperty( "revisionFirstSyncedAtUnix", out var rfs ) && rfs.ValueKind == JsonValueKind.Number ? rfs.GetInt64() : null;
if ( !_packageInfoDetected && _serverCurrentRevisionId.HasValue )
{
_currentRevisionId = _serverCurrentRevisionId;
_packageIdent = gp.TryGetProperty( "packageIdent", out var pi ) ? pi.GetString() : null;
_publishStatus = gp.TryGetProperty( "publishStatus", out var ps ) ? ps.GetString() : null;
_packageInfoDetected = true;
}
}
_serverEndpointOverrideCount = 0;
if ( _remoteEndpoints.HasValue && _remoteEndpoints.Value.ValueKind == JsonValueKind.Array )
{
foreach ( var ep in _remoteEndpoints.Value.EnumerateArray() )
{
if ( ep.TryGetProperty( "revisionTarget", out var rt ) && rt.ValueKind == JsonValueKind.String && rt.GetString() == "next" )
_serverEndpointOverrideCount++;
}
}
}
catch ( Exception ex )
{
Log.Info( $"[SyncTool] Server state refresh failed: {ex.Message}" );
}
}
/// <summary>
/// Send local package info to the backend so it always has the latest local revision.
/// </summary>
private async Task SyncLocalPackageInfoAsync()
{
try
{
await NetworkStoragePackageInfo.DetectAsync();
_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
if ( _packageInfoDetected )
{
_packageIdent = NetworkStoragePackageInfo.PackageIdent;
_currentRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;
_publishStatus = NetworkStoragePackageInfo.PublishStatus;
await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );
await RefreshServerPackageStateAsync();
Update();
}
}
catch ( Exception ex )
{
Log.Info( $"[SyncTool] Package info sync failed: {ex.Message}" );
}
}
// ── Revision change watcher ──
// Periodically re-detects package info and auto-syncs when the revision changes
// (e.g. after the developer publishes a new game revision from the s&box editor).
private long? _lastKnownRevisionId;
private float _revisionCheckTimer;
private const float RevisionCheckIntervalSeconds = 15f;
private bool _revisionSyncInFlight;
[EditorEvent.Frame]
private void OnEditorFrame()
{
if ( !SyncToolConfig.IsValid || _revisionSyncInFlight )
return;
_revisionCheckTimer += RealTime.Delta;
if ( _revisionCheckTimer < RevisionCheckIntervalSeconds )
return;
_revisionCheckTimer = 0;
_ = CheckRevisionChangeAsync();
}
private async Task CheckRevisionChangeAsync()
{
_revisionSyncInFlight = true;
try
{
await NetworkStoragePackageInfo.DetectAsync();
var newRevision = NetworkStoragePackageInfo.CurrentRevisionId;
if ( newRevision.HasValue && newRevision != _lastKnownRevisionId && _lastKnownRevisionId.HasValue )
{
Log.Info( $"[SyncTool] Revision changed: {_lastKnownRevisionId} → {newRevision}. Auto-syncing..." );
_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
_packageIdent = NetworkStoragePackageInfo.PackageIdent;
_currentRevisionId = newRevision;
_publishStatus = NetworkStoragePackageInfo.PublishStatus;
// Push updated package info to backend
var syncResp = await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );
if ( syncResp.HasValue )
Log.Info( $"[SyncTool] Auto-sync after revision change: ok" );
// Refresh server-side state
await RefreshServerPackageStateAsync();
Update();
}
_lastKnownRevisionId = newRevision ?? _lastKnownRevisionId;
}
catch ( Exception ex )
{
Log.Info( $"[SyncTool] Revision check failed: {ex.Message}" );
}
finally
{
_revisionSyncInFlight = false;
}
}
[Menu( "Editor", "Network Storage/Sync Tool" )]
public static void OpenWindow()
{
var window = new SyncToolWindow();
window.Show();
}
protected override bool OnClose()
{
_openWindowCount = Math.Max( 0, _openWindowCount - 1 );
return base.OnClose();
}
private void SetStatus( string message, bool isError = false )
{
_status = message;
_statusIsError = isError;
}
private void ShowGenerateFailure( string message, string detail = null )
{
SetStatus( message, isError: true );
MessageDialog.Show( "Generate Failed", message, detail );
}
private static string ExtractGenerateFailureSummary( string stdout, string stderr, int exitCode )
{
foreach ( var source in new[] { stderr, stdout } )
{
if ( string.IsNullOrWhiteSpace( source ) )
continue;
var lines = source
.Replace( "\r", "" )
.Split( '\n' )
.Select( x => x.Trim() )
.Where( x => !string.IsNullOrWhiteSpace( x ) )
.ToArray();
var explicitError = lines.FirstOrDefault( x => x.StartsWith( "ERROR:", StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( explicitError ) )
return explicitError["ERROR:".Length..].Trim();
var usefulLine = lines.FirstOrDefault( x =>
!x.StartsWith( "===", StringComparison.Ordinal ) &&
!x.StartsWith( "Project root:", StringComparison.OrdinalIgnoreCase ) &&
!x.StartsWith( "Data dir:", StringComparison.OrdinalIgnoreCase ) );
if ( !string.IsNullOrWhiteSpace( usefulLine ) )
return usefulLine;
}
return $"Generation failed (exit {exitCode})";
}
private static string BuildGenerateFailureDetail( string stdout, string stderr )
{
var detail = !string.IsNullOrWhiteSpace( stderr ) ? stderr.Trim() : stdout.Trim();
if ( string.IsNullOrWhiteSpace( detail ) )
return null;
const int maxLength = 4000;
if ( detail.Length > maxLength )
detail = detail[..maxLength] + "\n...";
return detail;
}
private void RefreshFileList()
{
var epDir = SyncToolConfig.Abs( SyncToolConfig.EndpointsPath );
_endpointFiles = Directory.Exists( epDir )
? FindResourceFiles( epDir, "endpoint" )
: Array.Empty<string>();
var colDir = SyncToolConfig.Abs( SyncToolConfig.CollectionsPath );
_collectionFiles = Directory.Exists( colDir )
? FindResourceFiles( colDir, "collection" )
: Array.Empty<string>();
var wfDir = SyncToolConfig.Abs( SyncToolConfig.WorkflowsPath );
_workflowFiles = Directory.Exists( wfDir )
? FindResourceFiles( wfDir, "workflow" )
: Array.Empty<string>();
}
private static string[] FindResourceFiles( string directory, string kind )
{
return SyncToolConfig.FindPayloadSourceFiles( kind, directory ).ToArray();
}
/// <summary>Shows which server this window talks to and the last request, so a non-default Base URL is never silent.</summary>
private static void DrawServerLines( float pad, float w, ref float y )
{
var baseUrl = SyncToolConfig.BaseUrl ?? "";
var isHosted = baseUrl.Contains( "api.sboxcool.com", StringComparison.OrdinalIgnoreCase );
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( isHosted ? Color.White.WithAlpha( 0.72f ) : Color.Orange.WithAlpha( 0.95f ) );
Paint.DrawText( new Rect( pad, y, w, 14 ), isHosted ? $"Server: {baseUrl}" : $"Server: {baseUrl} (self-hosted)", TextFlag.LeftCenter );
y += 18;
// Game code refuses some hosts. This only warns; the Sync Tool itself still works.
if ( SyncToolConfig.IsGameClientReachable( baseUrl, SyncToolConfig.ApiVersion ) == false )
{
Paint.SetDefaultFont( size: 8 );
Paint.SetPen( Color.Orange.WithAlpha( 0.85f ) );
Paint.DrawText( new Rect( pad, y, w, 12 ), SyncToolConfig.GameClientUnreachableLine1, TextFlag.LeftCenter );
y += 12;
Paint.DrawText( new Rect( pad, y, w, 12 ), SyncToolConfig.GameClientUnreachableLine2, TextFlag.LeftCenter );
y += 14;
Paint.SetDefaultFont( size: 9 );
}
if ( !string.IsNullOrEmpty( SyncToolApi.LastRequestSummary ) )
{
Paint.SetPen( Color.White.WithAlpha( 0.55f ) );
Paint.DrawText( new Rect( pad, y, w, 14 ), $"Last request: {SyncToolApi.LastRequestSummary}", TextFlag.LeftCenter );
y += 18;
}
}
private static string ResourceIdFromFile( string filePath, string kind )
=> SyncToolConfig.ResourceIdFromFilePath( filePath, kind );
// ──────────────────────────────────────────────────────
// Rendering
// ──────────────────────────────────────────────────────
protected override void OnContentPaint()
{
base.OnContentPaint();
_buttons.Clear();
var y = 38f;
var pad = 16f;
var w = Width - pad * 2;
// Push All + Test All buttons
if ( SyncToolConfig.IsValid )
{
var btnW2 = 92f;
var testAllW = 92f;
var pushAllRect = new Rect( pad + w - btnW2, y, btnW2, 22 );
var testAllRect = new Rect( pad + w - btnW2 - 4 - testAllW, y, testAllW, 22 );
DrawSmallButton( pushAllRect, PushAllLabel, Color.Green, "push_all", () => _ = PushAll() );
DrawSmallButton( testAllRect, TestAllLabel, Color.Cyan, "test_all", () => TestResultsWindow.OpenAndRun( publishTarget: _publishTarget ) );
}
y += 30;
// ── Config status ──
if ( !SyncToolConfig.IsValid )
{
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( Color.Red );
Paint.DrawText( new Rect( pad, y, w, 16 ), "Not configured — click Setup to enter your keys", TextFlag.LeftCenter );
y += 24;
}
else
{
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( pad, y, w, 14 ), $"Connected — {SyncToolConfig.ProjectId}", TextFlag.LeftCenter );
y += 18;
DrawServerLines( pad, w, ref y );
DrawCompatBanner( pad, w, ref y );
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.White.WithAlpha( 0.72f ) );
Paint.DrawText( new Rect( pad, y, w, 14 ),
$"Auth Sessions: {SyncToolConfig.AuthSessionsLabel} Encrypted Requests: {SyncToolConfig.EncryptedRequestsLabel}",
TextFlag.LeftCenter );
y += 20;
}
// ── Check for Updates / Pull from Web button ──
if ( SyncToolConfig.IsValid )
{
var checkBtnH = 26f;
var checkLabel = _hasCheckedRemote ? $"Pull from {PublishTargetLabel} (re-check)" : $"Check {PublishTargetLabel} for Updates";
var checkRect = new Rect( pad, y, w, checkBtnH );
DrawWideButton( checkRect, checkLabel, Color.Cyan, "check_updates", () => _ = CheckForUpdates() );
y += checkBtnH + 8;
var pullableCount = GetPullableResourceIds().Length;
if ( pullableCount > 0 )
{
var pullAllRect = new Rect( pad, y, w, checkBtnH );
DrawWideButton( pullAllRect, $"Pull All ({pullableCount}) from {(_publishTarget == "next" ? "Staged/Main" : "Live")}", Color.Cyan, "pull_all_changed",
() => PullAllChangedResources() );
y += checkBtnH + 8;
}
var remoteSemanticsCount = GetRemoteSemanticsCount();
if ( remoteSemanticsCount > 0 )
{
var semanticsLabel = remoteSemanticsCount == 1
? "Merge All (1 semantic item)"
: $"Merge All ({remoteSemanticsCount} semantic items)";
var semanticsRect = new Rect( pad, y, w, checkBtnH );
DrawWideButton( semanticsRect, semanticsLabel, Color.Green, "pull_remote_semantics_all",
() => PullAllRemoteSemantics() );
y += checkBtnH + 8;
}
}
// ── Package / Revision Info ──
if ( SyncToolConfig.IsValid )
{
if ( _packageInfoDetected )
{
var infoH = 104f;
Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
Paint.SetPen( Color.White.WithAlpha( 0.1f ) );
Paint.DrawRect( new Rect( pad, y, w, infoH ), 4 );
var col1 = pad + 8;
var col2 = pad + w * 0.48f;
var labelW = 90f;
var rowH = 16f;
var ry = y + 4;
// ═══ LEFT SIDE: Package Info ═══
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, 12 ), "REVISION / PACKAGE INFO", TextFlag.LeftCenter );
ry += 14;
// Row 1: Package
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Package:", TextFlag.LeftCenter );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, col2 - col1 - labelW - 8, rowH ), _packageIdent ?? "Unknown", TextFlag.LeftCenter );
// Row 2: Revision | Latest
ry += rowH;
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Revision:", TextFlag.LeftCenter );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, 60, rowH ), _serverCurrentRevisionId?.ToString() ?? "—", TextFlag.LeftCenter );
if ( _serverLatestRevisionId.HasValue && _serverLatestRevisionId != _serverCurrentRevisionId )
{
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1 + labelW + 65, ry, 50, rowH ), "Latest:", TextFlag.LeftCenter );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW + 115, ry, 60, rowH ), _serverLatestRevisionId.Value.ToString(), TextFlag.LeftCenter );
}
// Row 3: Last synced
ry += rowH;
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Last synced:", TextFlag.LeftCenter );
var syncedAgo = FormatSyncedAgo( _serverLastSyncedAt );
var syncedIsNever = string.IsNullOrWhiteSpace( _serverLastSyncedAt );
Paint.SetPen( syncedIsNever ? Color.Red.WithAlpha( 0.9f ) : Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), syncedAgo, TextFlag.LeftCenter );
// Row 4: First synced
ry += rowH;
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "First synced:", TextFlag.LeftCenter );
var firstSyncedAgo = FormatUnixAgo( _serverRevisionFirstSyncedAtUnix );
var firstIsNever = !_serverRevisionFirstSyncedAtUnix.HasValue;
Paint.SetPen( firstIsNever ? Color.White.WithAlpha( 0.4f ) : Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), firstSyncedAgo, TextFlag.LeftCenter );
// Row 5: Endpoint overrides (if any)
if ( _serverEndpointOverrideCount > 0 )
{
ry += rowH;
Paint.SetPen( Color.Yellow.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, rowH ), $"{_serverEndpointOverrideCount} endpoint(s) staged", TextFlag.LeftCenter );
}
// ═══ RIGHT SIDE: Publish Target ═══
var rightX = col2;
var rightW = w - (col2 - pad) - 8;
var cardH = 38f;
var cardGap = 4f;
var radioR = 5f;
var cardY = y + 4;
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( rightX, cardY, rightW, 12 ), "PUBLISH TARGET", TextFlag.LeftCenter );
cardY += 14;
var isNext = _publishTarget == "next";
var isLive = !isNext;
// Card: Staged/Main
var nextCardRect = new Rect( rightX, cardY, rightW, cardH );
Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
Paint.DrawRect( nextCardRect, 3 );
var radioX = rightX + 10;
var radioY = cardY + 10;
Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.9f ) : Color.Transparent );
Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );
var textX = rightX + 22;
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( isNext ? Color.Yellow : Color.White.WithAlpha( 0.85f ) );
Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Staged/Main", TextFlag.LeftCenter );
Paint.SetDefaultFont( size: 7 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Editor + next release", TextFlag.LeftCenter );
Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Live players unaffected", TextFlag.LeftCenter );
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = nextCardRect, Id = "publish-target-next", OnClick = () => { SetPublishTarget( "next" ); } } );
cardY += cardH + cardGap;
// Card: Live
var liveCardRect = new Rect( rightX, cardY, rightW, cardH );
Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
Paint.DrawRect( liveCardRect, 3 );
radioY = cardY + 10;
Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.9f ) : Color.Transparent );
Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( isLive ? Color.Green : Color.White.WithAlpha( 0.85f ) );
Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Live", TextFlag.LeftCenter );
Paint.SetDefaultFont( size: 7 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Production / existing live", TextFlag.LeftCenter );
Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Deployed immediately", TextFlag.LeftCenter );
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = liveCardRect, Id = "publish-target-live", OnClick = () => { SetPublishTarget( "live" ); } } );
y += infoH + 6;
}
else
{
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.Yellow.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( pad, y + 4, w, 16 ), "No package revision detected — publishing will use default live behavior.", TextFlag.LeftCenter );
y += 26;
}
}
DrawSeparator( ref y, w, pad );
// ── Begin scrollable content ──
_scrollAreaTop = y;
y -= _scrollY;
// ── Data Sources ──
if ( SyncToolConfig.SyncMappings.Count > 0 )
{
DrawDataSourcesSection( ref y, pad, w );
DrawSeparator( ref y, w, pad );
}
// ── Build item sets for all sections ──
var remoteEpSlugs = GetRemoteEndpointSlugs();
var allSlugs = new HashSet<string>();
foreach ( var f in _endpointFiles ) allSlugs.Add( ResourceIdFromFile( f, "endpoint" ) );
foreach ( var s in remoteEpSlugs ) allSlugs.Add( s );
var localColNames = _collectionFiles.Select( f => ResourceIdFromFile( f, "collection" ) ).ToHashSet();
var remoteColNames = GetRemoteCollectionNames();
var allColNames = new HashSet<string>( localColNames );
foreach ( var n in remoteColNames ) allColNames.Add( n );
var localWfIds = _workflowFiles.Select( f => ResourceIdFromFile( f, "workflow" ) ).ToHashSet();
var remoteWfIds = GetRemoteWorkflowIds();
var allWfIds = new HashSet<string>( localWfIds );
foreach ( var id2 in remoteWfIds ) allWfIds.Add( id2 );
// ── CHANGES section (only after checking remote) ──
if ( _hasCheckedRemote )
{
var changedIds = _items
.Where( kv => kv.Value.Status != SyncStatus.Unknown && kv.Value.Status != SyncStatus.InSync )
.OrderBy( kv => kv.Key )
.Select( kv => kv.Key )
.ToList();
if ( changedIds.Count > 0 )
{
DrawSectionHeader( ref y, pad, w, $"CHANGES ({changedIds.Count})" );
foreach ( var changedId in changedIds )
DrawChangedItemRow( ref y, pad, w, changedId );
DrawSeparator( ref y, w, pad );
}
}
// ── Endpoints ──
var syncedEpSlugs = allSlugs
.Where( s => !_hasCheckedRemote || GetItemStatus( $"ep_{s}" ) == SyncStatus.InSync || GetItemStatus( $"ep_{s}" ) == SyncStatus.Unknown )
.OrderBy( s => s ).ToList();
DrawSectionHeader( ref y, pad, w, $"ENDPOINTS ({allSlugs.Count})" );
if ( syncedEpSlugs.Count > 0 )
{
foreach ( var slug in syncedEpSlugs )
{
var id = $"ep_{slug}";
var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
var hasLocal = localFile != null;
var deprecated = hasLocal && IsEndpointDeprecated( localFile );
var info = deprecated ? $"{GetEndpointInfo( localFile )} - deprecated, ignored by sync" : hasLocal ? GetEndpointInfo( localFile ) : "remote only";
var capturedSlug = slug;
DrawResourceRow( ref y, pad, w, $"{slug}.endpoint.yml", info, id,
hasLocal && !deprecated ? () => PushItem( id ) : null,
deprecated ? null : () => PullItem( id ),
deprecated ? null : () => TestResultsWindow.OpenAndRun( capturedSlug, _publishTarget ),
deprecated, staged: IsEndpointStaged( slug ) );
}
}
else if ( allSlugs.Count == 0 )
{
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
Paint.DrawText( new Rect( pad + 8, y, w, 16 ), "No endpoint files found", TextFlag.LeftCenter );
y += 22;
}
DrawSeparator( ref y, w, pad );
// ── Collections ──
var syncedColNames = allColNames
.Where( n => !_hasCheckedRemote || GetItemStatus( $"col_{n}" ) == SyncStatus.InSync || GetItemStatus( $"col_{n}" ) == SyncStatus.Unknown )
.OrderBy( n => n ).ToList();
DrawSectionHeader( ref y, pad, w, $"COLLECTIONS ({allColNames.Count})" );
if ( syncedColNames.Count > 0 )
{
foreach ( var colName in syncedColNames )
{
var id = $"col_{colName}";
var hasLocal = localColNames.Contains( colName );
DrawResourceRow( ref y, pad, w, $"{colName}.collection.yml", hasLocal ? "schema" : "remote only", id,
hasLocal ? () => PushItem( id ) : null,
() => PullItem( id ) );
}
}
else if ( allColNames.Count == 0 )
{
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
Paint.DrawText( new Rect( pad + 8, y, w, 16 ), "No collections found", TextFlag.LeftCenter );
y += 22;
}
DrawSeparator( ref y, w, pad );
// ── Workflows ──
var syncedWfIds = allWfIds
.Where( id2 => !_hasCheckedRemote || GetItemStatus( $"wf_{id2}" ) == SyncStatus.InSync || GetItemStatus( $"wf_{id2}" ) == SyncStatus.Unknown )
.OrderBy( n => n ).ToList();
DrawSectionHeader( ref y, pad, w, $"WORKFLOWS ({allWfIds.Count})" );
if ( syncedWfIds.Count > 0 )
{
foreach ( var wfId in syncedWfIds )
{
var itemId = $"wf_{wfId}";
var hasLocal = localWfIds.Contains( wfId );
var info = hasLocal ? GetWorkflowInfo( SyncToolConfig.FindWorkflowFileById( wfId ) ) : "remote only";
DrawResourceRow( ref y, pad, w, $"{wfId}.workflow.yml", info, itemId,
hasLocal ? () => PushItem( itemId ) : null,
() => PullItem( itemId ) );
}
}
else if ( allWfIds.Count == 0 )
{
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( Color.White.WithAlpha( 0.3f ) );
Paint.DrawText( new Rect( pad + 8, y, w, 16 ), "No workflow files found", TextFlag.LeftCenter );
y += 22;
}
y += 8;
DrawSeparator( ref y, w, pad );
// ── Status bar ──
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( _busy ? Color.Yellow : _statusIsError ? Color.Red.WithAlpha( 0.9f ) : Color.White.WithAlpha( 0.4f ) );
Paint.DrawText( new Rect( pad, y, w, 16 ), _status, TextFlag.LeftCenter );
y += 22;
// ── Sync log (shown after push/pull) ──
if ( _syncLog.Count > 0 )
{
DrawSeparator( ref y, w, pad );
DrawSectionHeader( ref y, pad, w, "SYNC RESULTS" );
var okCount = _syncLog.Count( e => e.Ok && IsResourceSyncLog( e ) );
var failCount = _syncLog.Count( e => !e.Ok && IsResourceSyncLog( e ) && !IsBlockedLog( e ) );
var blockedCount = _syncLog.Count( e => IsBlockedLog( e ) );
var verifiedCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Verified" ) );
var mismatchCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Mismatch" ) );
// Summary counts
Paint.SetDefaultFont( size: 9, weight: 600 );
var sx = pad + 8f;
if ( okCount > 0 )
{
Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( sx, y, 90, 16 ), $"✓ {okCount} pushed", TextFlag.LeftCenter );
sx += 80;
}
if ( verifiedCount > 0 )
{
Paint.SetPen( Color.Cyan.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( sx, y, 90, 16 ), $"● {verifiedCount} verified", TextFlag.LeftCenter );
sx += 85;
}
if ( mismatchCount > 0 )
{
Paint.SetPen( Color.Orange.WithAlpha( 0.9f ) );
Paint.DrawText( new Rect( sx, y, 100, 16 ), $"▲ {mismatchCount} mismatch", TextFlag.LeftCenter );
sx += 90;
}
var mergeLogCount = _syncLog.Count( e => e.Detail != null && e.Detail.Contains( "Remote semantics available" ) );
if ( mergeLogCount > 0 )
{
Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( sx, y, 140, 16 ), $"⇄ {mergeLogCount} semantics", TextFlag.LeftCenter );
sx += 120;
}
if ( failCount > 0 )
{
Paint.SetPen( Color.Red.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( sx, y, 90, 16 ), $"✗ {failCount} failed", TextFlag.LeftCenter );
sx += 90;
}
if ( blockedCount > 0 )
{
Paint.SetPen( Color.White.WithAlpha( 0.6f ) );
Paint.DrawText( new Rect( sx, y, 110, 16 ), $"○ {blockedCount} not pushed", TextFlag.LeftCenter );
}
y += 22;
// Per-item log
foreach ( var entry in _syncLog )
{
if ( y > _scrollAreaTop && y < Height )
{
// Icon
var isMismatch = entry.Detail != null && entry.Detail.Contains( "Mismatch" );
var isVerified = entry.Detail != null && entry.Detail.Contains( "Verified" );
var isMergeAvail = entry.Detail != null && entry.Detail.Contains( "Remote semantics available" );
var icon = entry.Warn ? "⚠"
: isMergeAvail ? "⇄"
: entry.Ok ? ( isVerified ? "●" : "✓" )
: ( isMismatch ? "▲" : "✗" );
var iconColor = entry.Warn ? Color.Orange.WithAlpha( 0.9f )
: isMergeAvail ? Color.Green.WithAlpha( 0.7f )
: entry.Ok ? ( isVerified ? Color.Cyan.WithAlpha( 0.7f ) : Color.Green.WithAlpha( 0.6f ) )
: ( isMismatch ? Color.Orange.WithAlpha( 0.8f ) : Color.Red.WithAlpha( 0.7f ) );
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( iconColor );
Paint.DrawText( new Rect( pad + 8, y, 16, 16 ), icon, TextFlag.Center );
// Name
Paint.SetPen( Color.White.WithAlpha( 0.8f ) );
var nameW = w * 0.35f;
Paint.DrawText( new Rect( pad + 26, y, nameW, 16 ), entry.Name, TextFlag.LeftCenter );
// Detail
var detailText = entry.Detail ?? ( entry.Ok ? "Pushed" : "Failed" );
var detailColor = entry.Warn ? Color.Orange.WithAlpha( 0.8f )
: isMismatch ? Color.Orange.WithAlpha( 0.8f )
: isVerified ? Color.Cyan.WithAlpha( 0.6f )
: entry.Ok ? Color.Green.WithAlpha( 0.5f )
: Color.Red.WithAlpha( 0.6f );
Paint.SetDefaultFont( size: 8 );
Paint.SetPen( detailColor );
DrawOneLineText( new Rect( pad + 26 + nameW + 4, y, w - nameW - 100, 16 ), detailText, TextFlag.LeftCenter, entry.Name, $"synclog_{_syncLog.IndexOf( entry )}" );
// Type badge
Paint.SetPen( Color.White.WithAlpha( 0.25f ) );
Paint.DrawText( new Rect( pad + w - 70, y, 70, 16 ), entry.Type, TextFlag.RightCenter );
}
y += 18;
}
y += 8;
}
// ── Record content height for scrollbar ──
_contentHeight = ( y + _scrollY ) - _scrollAreaTop;
// ── Redraw header background to cover scrolled content ──
Paint.SetBrush( new Color( 0.133f, 0.133f, 0.133f ) );
Paint.SetPen( Color.Transparent );
Paint.DrawRect( new Rect( 0, 0, Width, _scrollAreaTop ) );
// Re-draw header (replayed from top)
RedrawHeader( pad, w );
// ── Scrollbar ──
if ( MaxScroll > 0 )
{
var trackX = Width - 8;
var trackH = Height - _scrollAreaTop - 4;
var viewRatio = ( Height - _scrollAreaTop ) / _contentHeight;
var thumbH = Math.Max( 20, trackH * viewRatio );
var thumbY = _scrollAreaTop + ( _scrollY / MaxScroll ) * ( trackH - thumbH );
Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
Paint.SetPen( Color.Transparent );
Paint.DrawRect( new Rect( trackX, _scrollAreaTop, 6, trackH ) );
Paint.SetBrush( Color.White.WithAlpha( 0.15f ) );
Paint.DrawRect( new Rect( trackX, thumbY, 6, thumbH ), 3 );
}
}
/// <summary>
/// Redraws the fixed header area on top of scrolled content so it doesn't bleed through.
/// </summary>
private void RedrawHeader( float pad, float w )
{
var y = 38f;
// Brand header (approved icon + short title, never clips at minimum width) + Push All + Test All
NetworkStorageBrand.DrawIcon( pad, y, 22f );
Paint.SetDefaultFont( size: 13, weight: 700 );
Paint.SetPen( Color.White );
Paint.DrawText( new Rect( pad + 30f, y, w * 0.55f, 22 ), Title, TextFlag.LeftCenter );
if ( SyncToolConfig.IsValid )
{
var btnW2 = 92f;
var testAllW = 92f;
var pushAllRect = new Rect( pad + w - btnW2, y, btnW2, 22 );
var testAllRect = new Rect( pad + w - btnW2 - 4 - testAllW, y, testAllW, 22 );
DrawSmallButton( pushAllRect, PushAllLabel, Color.Green, "push_all", () => _ = PushAll() );
DrawSmallButton( testAllRect, TestAllLabel, Color.Cyan, "test_all", () => TestResultsWindow.OpenAndRun( publishTarget: _publishTarget ) );
}
y += 30;
// Config status
if ( !SyncToolConfig.IsValid )
{
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( Color.Red );
Paint.DrawText( new Rect( pad, y, w, 16 ), "Not configured — click Setup to enter your keys", TextFlag.LeftCenter );
y += 24;
}
else
{
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( pad, y, w, 14 ), $"Connected — {SyncToolConfig.ProjectId}", TextFlag.LeftCenter );
y += 18;
DrawServerLines( pad, w, ref y );
DrawCompatBanner( pad, w, ref y );
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.White.WithAlpha( 0.72f ) );
Paint.DrawText( new Rect( pad, y, w, 14 ),
$"Auth Sessions: {SyncToolConfig.AuthSessionsLabel} Encrypted Requests: {SyncToolConfig.EncryptedRequestsLabel}",
TextFlag.LeftCenter );
y += 20;
}
// Check for Updates button
if ( SyncToolConfig.IsValid )
{
var checkBtnH = 26f;
var checkLabel = _hasCheckedRemote ? $"Pull from {PublishTargetLabel} (re-check)" : $"Check {PublishTargetLabel} for Updates";
var checkRect = new Rect( pad, y, w, checkBtnH );
DrawWideButton( checkRect, checkLabel, Color.Cyan, "check_updates", () => _ = CheckForUpdates() );
y += checkBtnH + 8;
var pullableCount = GetPullableResourceIds().Length;
if ( pullableCount > 0 )
{
var pullAllRect = new Rect( pad, y, w, checkBtnH );
DrawWideButton( pullAllRect, $"Pull All ({pullableCount}) from {(_publishTarget == "next" ? "Staged/Main" : "Live")}", Color.Cyan, "pull_all_changed",
() => PullAllChangedResources() );
y += checkBtnH + 8;
}
var remoteSemanticsCount = GetRemoteSemanticsCount();
if ( remoteSemanticsCount > 0 )
{
var semanticsLabel = remoteSemanticsCount == 1
? "Merge All (1 semantic item)"
: $"Merge All ({remoteSemanticsCount} semantic items)";
var semanticsRect = new Rect( pad, y, w, checkBtnH );
DrawWideButton( semanticsRect, semanticsLabel, Color.Green, "pull_remote_semantics_all",
() => PullAllRemoteSemantics() );
y += checkBtnH + 8;
}
}
// ── Package / Revision Info ──
if ( SyncToolConfig.IsValid )
{
if ( _packageInfoDetected )
{
var infoH = 104f;
Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
Paint.SetPen( Color.White.WithAlpha( 0.1f ) );
Paint.DrawRect( new Rect( pad, y, w, infoH ), 4 );
var col1 = pad + 8;
var col2 = pad + w * 0.48f;
var labelW = 90f;
var rowH = 16f;
var ry = y + 4;
// ═══ LEFT SIDE: Package Info ═══
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, 12 ), "REVISION / PACKAGE INFO", TextFlag.LeftCenter );
ry += 14;
// Row 1: Package
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Package:", TextFlag.LeftCenter );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, col2 - col1 - labelW - 8, rowH ), _packageIdent ?? "Unknown", TextFlag.LeftCenter );
// Row 2: Revision | Latest
ry += rowH;
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Revision:", TextFlag.LeftCenter );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, 60, rowH ), _serverCurrentRevisionId?.ToString() ?? "—", TextFlag.LeftCenter );
if ( _serverLatestRevisionId.HasValue && _serverLatestRevisionId != _serverCurrentRevisionId )
{
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1 + labelW + 65, ry, 50, rowH ), "Latest:", TextFlag.LeftCenter );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW + 115, ry, 60, rowH ), _serverLatestRevisionId.Value.ToString(), TextFlag.LeftCenter );
}
// Row 3: Last synced
ry += rowH;
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "Last synced:", TextFlag.LeftCenter );
var syncedAgo = FormatSyncedAgo( _serverLastSyncedAt );
var syncedIsNever = string.IsNullOrWhiteSpace( _serverLastSyncedAt );
Paint.SetPen( syncedIsNever ? Color.Red.WithAlpha( 0.9f ) : Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), syncedAgo, TextFlag.LeftCenter );
// Row 4: First synced
ry += rowH;
Paint.SetPen( Color.White.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( col1, ry, labelW, rowH ), "First synced:", TextFlag.LeftCenter );
var firstSyncedAgo = FormatUnixAgo( _serverRevisionFirstSyncedAtUnix );
var firstIsNever = !_serverRevisionFirstSyncedAtUnix.HasValue;
Paint.SetPen( firstIsNever ? Color.White.WithAlpha( 0.4f ) : Color.White.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( col1 + labelW, ry, 120, rowH ), firstSyncedAgo, TextFlag.LeftCenter );
// Row 5: Endpoint overrides (if any)
if ( _serverEndpointOverrideCount > 0 )
{
ry += rowH;
Paint.SetPen( Color.Yellow.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( col1, ry, col2 - col1 - 8, rowH ), $"{_serverEndpointOverrideCount} endpoint(s) staged", TextFlag.LeftCenter );
}
// ═══ RIGHT SIDE: Publish Target ═══
var rightX = col2;
var rightW = w - (col2 - pad) - 8;
var cardH = 38f;
var cardGap = 4f;
var radioR = 5f;
var cardY = y + 4;
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( rightX, cardY, rightW, 12 ), "PUBLISH TARGET", TextFlag.LeftCenter );
cardY += 14;
var isNext = _publishTarget == "next";
var isLive = !isNext;
// Card: Staged/Main
var nextCardRect = new Rect( rightX, cardY, rightW, cardH );
Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
Paint.DrawRect( nextCardRect, 3 );
var radioX = rightX + 10;
var radioY = cardY + 10;
Paint.SetBrush( isNext ? Color.Yellow.WithAlpha( 0.9f ) : Color.Transparent );
Paint.SetPen( isNext ? Color.Yellow.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );
var textX = rightX + 22;
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( isNext ? Color.Yellow : Color.White.WithAlpha( 0.85f ) );
Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Staged/Main", TextFlag.LeftCenter );
Paint.SetDefaultFont( size: 7 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Editor + next release", TextFlag.LeftCenter );
Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Live players unaffected", TextFlag.LeftCenter );
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = nextCardRect, Id = "publish-target-next", OnClick = () => { SetPublishTarget( "next" ); } } );
cardY += cardH + cardGap;
// Card: Live
var liveCardRect = new Rect( rightX, cardY, rightW, cardH );
Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.08f ) : Color.White.WithAlpha( 0.02f ) );
Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.3f ) : Color.White.WithAlpha( 0.08f ) );
Paint.DrawRect( liveCardRect, 3 );
radioY = cardY + 10;
Paint.SetBrush( isLive ? Color.Green.WithAlpha( 0.9f ) : Color.Transparent );
Paint.SetPen( isLive ? Color.Green.WithAlpha( 0.8f ) : Color.White.WithAlpha( 0.25f ) );
Paint.DrawCircle( new Vector2( radioX, radioY ), radioR );
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( isLive ? Color.Green : Color.White.WithAlpha( 0.85f ) );
Paint.DrawText( new Rect( textX, cardY + 3, rightW - 26, 13 ), "Live", TextFlag.LeftCenter );
Paint.SetDefaultFont( size: 7 );
Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( textX, cardY + 15, rightW - 26, 11 ), "Production / existing live", TextFlag.LeftCenter );
Paint.DrawText( new Rect( textX, cardY + 25, rightW - 26, 11 ), "Deployed immediately", TextFlag.LeftCenter );
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = liveCardRect, Id = "publish-target-live", OnClick = () => { SetPublishTarget( "live" ); } } );
y += infoH + 6;
}
else
{
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.Yellow.WithAlpha( 0.75f ) );
Paint.DrawText( new Rect( pad, y + 4, w, 16 ), "No package revision detected — publishing will use default live behavior.", TextFlag.LeftCenter );
y += 26;
}
}
DrawSeparator( ref y, w, pad );
}
// ──────────────────────────────────────────────────────
// Drawing helpers
// ──────────────────────────────────────────────────────
private void DrawSectionHeader( ref float y, float pad, float w, string title )
{
Paint.SetDefaultFont( size: 10, weight: 700 );
Paint.SetPen( Color.White.WithAlpha( 0.7f ) );
Paint.DrawText( new Rect( pad, y, w, 18 ), title, TextFlag.LeftCenter );
y += 24;
}
private static string FormatSyncedAgo( string isoTimestamp )
{
if ( string.IsNullOrWhiteSpace( isoTimestamp ) )
return "Never";
if ( !DateTimeOffset.TryParse( isoTimestamp, out var ts ) )
return isoTimestamp;
var ago = DateTimeOffset.UtcNow - ts;
if ( ago.TotalSeconds < 60 ) return "Just now";
if ( ago.TotalMinutes < 60 ) return $"{(int)ago.TotalMinutes}m ago";
if ( ago.TotalHours < 24 ) return $"{(int)ago.TotalHours}h ago";
if ( ago.TotalDays < 7 ) return $"{(int)ago.TotalDays}d ago";
return ts.ToString( "yyyy-MM-dd HH:mm" );
}
private static string FormatUnixAgo( long? unixSeconds )
{
if ( !unixSeconds.HasValue || unixSeconds.Value <= 0 )
return "Never";
var ts = DateTimeOffset.FromUnixTimeSeconds( unixSeconds.Value );
var ago = DateTimeOffset.UtcNow - ts;
if ( ago.TotalSeconds < 60 ) return "Just now";
if ( ago.TotalMinutes < 60 ) return $"{(int)ago.TotalMinutes}m ago";
if ( ago.TotalHours < 24 ) return $"{(int)ago.TotalHours}h ago";
if ( ago.TotalDays < 7 ) return $"{(int)ago.TotalDays}d ago";
return ts.ToString( "yyyy-MM-dd HH:mm" );
}
private bool IsEndpointStaged( string slug )
{
if ( !_remoteEndpoints.HasValue || _remoteEndpoints.Value.ValueKind != JsonValueKind.Array )
return false;
foreach ( var ep in _remoteEndpoints.Value.EnumerateArray() )
{
if ( ep.TryGetProperty( "slug", out var s ) && s.GetString() == slug &&
ep.TryGetProperty( "revisionTarget", out var rt ) && rt.GetString() == "next" )
return true;
}
return false;
}
private void DrawSeparator( ref float y, float w, float pad )
{
Paint.SetPen( Color.White.WithAlpha( 0.08f ) );
Paint.DrawLine( new Vector2( pad, y ), new Vector2( pad + w, y ) );
y += 8;
}
private void DrawSmallButton( Rect rect, string label, Color color, string id, Action onClick )
{
var hovered = rect.IsInside( _mousePos );
var isBusy = _busy && _busyItem == id;
Paint.SetBrush( color.WithAlpha( isBusy ? 0.2f : hovered ? 0.15f : 0.08f ) );
Paint.SetPen( color.WithAlpha( hovered ? 0.5f : 0.25f ) );
Paint.DrawRect( rect, 3 );
Paint.SetDefaultFont( size: 9, weight: 600 );
Paint.SetPen( isBusy ? Color.Yellow : color.WithAlpha( hovered ? 1f : 0.7f ) );
Paint.DrawText( rect, isBusy ? "..." : label, TextFlag.Center );
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = rect, Id = id, OnClick = onClick } );
}
/// <summary>
/// The first line of <paramref name="text"/>, cut to <paramref name="maxWidth"/> with an ellipsis. Rows are one line
/// high, so a multi-line message shows its first line and the full text opens on click.
/// </summary>
private static string FitToOneLine( string text, float maxWidth )
{
if ( string.IsNullOrEmpty( text ) ) return "";
var trimmed = text.Trim();
var lineEnd = trimmed.IndexOfAny( new[] { '\r', '\n' } );
var line = lineEnd < 0 ? trimmed : trimmed[..lineEnd].TrimEnd() + " …";
while ( line.Length > 1 && Paint.MeasureText( line ).x > maxWidth )
line = line[..^2] + "…";
return line;
}
/// <summary>Draws one line of text. When the full text does not fit, clicking the row opens it in a dialog.</summary>
private void DrawOneLineText( Rect rect, string text, TextFlag flag, string title, string regionId )
{
var fullText = (text ?? "").Trim();
var shown = FitToOneLine( fullText, rect.Width );
Paint.DrawText( rect, shown, flag );
if ( !_busy && shown != fullText )
_buttons.Add( new ClickRegion { Rect = rect, Id = regionId, OnClick = () => MessageDialog.Show( title, fullText ) } );
}
private void DrawWideButton( Rect rect, string label, Color color, string id, Action onClick )
{
var hovered = rect.IsInside( _mousePos );
var isBusy = _busy && _busyItem == id;
Paint.SetBrush( color.WithAlpha( isBusy ? 0.15f : hovered ? 0.1f : 0.05f ) );
Paint.SetPen( color.WithAlpha( hovered ? 0.4f : 0.2f ) );
Paint.DrawRect( rect, 4 );
Paint.SetDefaultFont( size: 10, weight: 600 );
Paint.SetPen( isBusy ? Color.Yellow : color.WithAlpha( hovered ? 0.9f : 0.6f ) );
Paint.DrawText( rect, isBusy ? "Checking..." : label, TextFlag.Center );
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = rect, Id = id, OnClick = onClick } );
}
private void DrawResourceRow( ref float y, float pad, float w, string name, string info, string id,
Action pushAction, Action pullAction, Action testAction = null, bool deprecated = false, bool staged = false )
{
var rowH = 28f;
var btnW = 48f;
var btnH = 20f;
var rowRect = new Rect( pad, y, w, rowH );
var hovered = rowRect.IsInside( _mousePos );
if ( hovered )
{
Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
Paint.SetPen( Color.Transparent );
Paint.DrawRect( rowRect, 3 );
}
// Status icon + label (leftmost)
_items.TryGetValue( id, out var state );
var hasResult = !string.IsNullOrEmpty( state.SyncResult );
var hasStatusBadge = _hasCheckedRemote || hasResult;
var hasIndicator = hasResult || state.RemoteDiffers || state.Status == SyncStatus.MergeAvailable || ( _hasCheckedRemote && state.Status != SyncStatus.Unknown );
// Icon
if ( hasResult )
{
var iconColor = state.SyncResult == "OK" ? Color.Green.WithAlpha( 0.8f ) : Color.Red.WithAlpha( 0.8f );
Paint.SetPen( iconColor );
Paint.SetDefaultFont( size: 9 );
Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), state.SyncResult == "OK" ? "✓" : "✗", TextFlag.Center );
}
else if ( state.Status == SyncStatus.MergeAvailable )
{
Paint.SetPen( Color.Green.WithAlpha( 0.8f ) );
Paint.SetDefaultFont( size: 9 );
Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "⇄", TextFlag.Center );
}
else if ( state.RemoteDiffers || state.Status == SyncStatus.Differs )
{
Paint.SetPen( Color.Orange.WithAlpha( 0.8f ) );
Paint.SetDefaultFont( size: 9 );
Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "●", TextFlag.Center );
}
else if ( state.Status == SyncStatus.LocalOnly )
{
Paint.SetPen( Color.Yellow.WithAlpha( 0.8f ) );
Paint.SetDefaultFont( size: 9 );
Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "▲", TextFlag.Center );
}
else if ( state.Status == SyncStatus.InSync )
{
Paint.SetPen( Color.Green.WithAlpha( 0.5f ) );
Paint.SetDefaultFont( size: 9 );
Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "✓", TextFlag.Center );
}
else if ( state.Status == SyncStatus.RemoteOnly )
{
Paint.SetPen( Color.Cyan.WithAlpha( 0.8f ) );
Paint.SetDefaultFont( size: 9 );
Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), "▼", TextFlag.Center );
}
var contentX = pad + ( hasIndicator ? 22 : 8 );
var btnY = y + ( rowH - btnH ) / 2;
// Pull/Review button — LEFT side (only if remote has changes to pull)
if ( state.Status == SyncStatus.MergeAvailable )
{
var mergeW = 56f;
var mergeRect = new Rect( contentX, btnY, mergeW, btnH );
var capturedMergeId = id;
var capturedMergeName = name;
DrawSmallButton( mergeRect, "Review", Color.Green, $"merge_{id}",
() => OpenMergeView( capturedMergeId, capturedMergeName ) );
contentX += mergeW + 6;
}
else if ( pullAction != null && ( state.RemoteDiffers || _hasCheckedRemote && state.Status != SyncStatus.LocalOnly && state.Status != SyncStatus.Unknown ) )
{
var pullRect = new Rect( contentX, btnY, btnW, btnH );
DrawSmallButton( pullRect, "Pull", Color.Cyan, $"pull_{id}", pullAction );
contentX += btnW + 6;
}
// File name
// Calculate right-side button area width
var rightBtnsW = 4f;
if ( pushAction != null ) rightBtnsW += btnW + 4;
if ( testAction != null ) rightBtnsW += btnW + 4;
Paint.SetDefaultFont( size: 10 );
Paint.SetPen( deprecated ? Color.White.WithAlpha( 0.4f ) : Color.White.WithAlpha( 0.9f ) );
var badgeReserve = hasStatusBadge && !hasResult ? 86f : 70f;
var nameW = w - ( contentX - pad ) - rightBtnsW - badgeReserve;
Paint.DrawText( new Rect( contentX, y, nameW, rowH ), name, TextFlag.LeftCenter );
// Deployment target indicator (only for endpoints with remote data)
var afterNameX = contentX + Paint.MeasureText( name ).x + 4;
if ( id.StartsWith( "ep_" ) && _hasCheckedRemote && state.Status != SyncStatus.LocalOnly )
{
Paint.SetDefaultFont( size: 7 );
var targetText = staged ? "Staged" : "Live";
var targetColor = staged ? Color.Yellow : Color.Green;
var targetW = Paint.MeasureText( targetText ).x + 6;
var targetRect = new Rect( afterNameX, y + ( rowH - 12 ) / 2, targetW, 12 );
Paint.SetBrush( targetColor.WithAlpha( 0.1f ) );
Paint.SetPen( targetColor.WithAlpha( 0.25f ) );
Paint.DrawRect( targetRect, 2 );
Paint.SetPen( targetColor.WithAlpha( 0.7f ) );
Paint.DrawText( targetRect, targetText, TextFlag.Center );
afterNameX += targetW + 4;
}
// Deprecated badge
if ( deprecated )
{
Paint.SetDefaultFont( size: 7 );
var depText = "deprecated";
var depTextW = Paint.MeasureText( depText ).x + 8;
var depBadgeRect = new Rect( afterNameX, y + ( rowH - 14 ) / 2, depTextW, 14 );
Paint.SetBrush( new Color( 0.96f, 0.62f, 0.04f, 0.12f ) );
Paint.SetPen( new Color( 0.96f, 0.62f, 0.04f, 0.25f ) );
Paint.DrawRect( depBadgeRect, 3 );
Paint.SetPen( new Color( 0.96f, 0.62f, 0.04f, 0.7f ) );
Paint.DrawText( depBadgeRect, depText, TextFlag.Center );
}
// Status badge (right of name, before buttons)
float statusBadgeEndX = contentX + nameW + 4;
if ( hasStatusBadge && !hasResult )
{
// Skip the simple "Synced" badge if we'll show "Synced: live" or "Synced: revision" below
var skipSyncedBadge = state.Status == SyncStatus.InSync && ( staged || _packageInfoDetected );
var (badgeText, badgeColor) = state.Status switch
{
SyncStatus.InSync => skipSyncedBadge ? ("", Color.Transparent) : ("Synced", Color.Green.WithAlpha( 0.5f )),
SyncStatus.LocalOnly => ("Local", Color.Yellow.WithAlpha( 0.7f )),
SyncStatus.RemoteOnly => ("Remote", Color.Cyan.WithAlpha( 0.7f )),
SyncStatus.Differs => ("Changed", Color.Orange.WithAlpha( 0.7f )),
SyncStatus.MergeAvailable => ("Semantic", Color.Green.WithAlpha( 0.7f )),
_ => ("", Color.White.WithAlpha( 0.3f ))
};
if ( !string.IsNullOrEmpty( badgeText ) )
{
Paint.SetDefaultFont( size: 8 );
Paint.SetPen( badgeColor );
var badgeX = contentX + nameW + 4;
var badgeW = Math.Max( 50f, Paint.MeasureText( badgeText ).x + 8 );
Paint.DrawText( new Rect( badgeX, y, badgeW, rowH ), badgeText, TextFlag.LeftCenter );
statusBadgeEndX = badgeX + badgeW + 4;
}
}
// Deployment status badge (only show when actually in sync)
if ( state.Status == SyncStatus.InSync )
{
if ( staged )
{
var nextRev = ((_serverCurrentRevisionId ?? _currentRevisionId) ?? 0) + 1;
var revText = $"Synced: rev {nextRev}";
Paint.SetDefaultFont( size: 7 );
var revW = Paint.MeasureText( revText ).x + 10;
var revRect = new Rect( statusBadgeEndX, y + ( rowH - 14 ) / 2, revW, 14 );
Paint.SetBrush( Color.Yellow.WithAlpha( 0.1f ) );
Paint.SetPen( Color.Yellow.WithAlpha( 0.3f ) );
Paint.DrawRect( revRect, 3 );
Paint.SetPen( Color.Yellow.WithAlpha( 0.7f ) );
Paint.DrawText( revRect, revText, TextFlag.Center );
}
else if ( _packageInfoDetected )
{
var revText = "Synced: live";
Paint.SetDefaultFont( size: 7 );
var revW = Paint.MeasureText( revText ).x + 10;
var revRect = new Rect( statusBadgeEndX, y + ( rowH - 14 ) / 2, revW, 14 );
Paint.SetBrush( Color.Green.WithAlpha( 0.1f ) );
Paint.SetPen( Color.Green.WithAlpha( 0.3f ) );
Paint.DrawRect( revRect, 3 );
Paint.SetPen( Color.Green.WithAlpha( 0.7f ) );
Paint.DrawText( revRect, revText, TextFlag.Center );
}
}
// Test button — RIGHT side, before Push
var rightX = pad + w - 4;
if ( pushAction != null )
{
rightX -= btnW;
var pushRect = new Rect( rightX, btnY, btnW, btnH );
DrawSmallButton( pushRect, "Push", Color.White, $"push_{id}", pushAction );
rightX -= 4;
}
if ( testAction != null )
{
rightX -= btnW;
var testRect = new Rect( rightX, btnY, btnW, btnH );
DrawSmallButton( testRect, "Test", Color.Cyan, $"test_{id}", _busy ? null : testAction );
}
y += rowH + 1;
// Diff/status details (below the row)
if ( !string.IsNullOrEmpty( state.DiffSummary ) && ( state.RemoteDiffers || state.Status == SyncStatus.LocalOnly || state.Status == SyncStatus.MergeAvailable ) )
{
// Summary text
var summaryColor = state.Status == SyncStatus.MergeAvailable ? Color.Green.WithAlpha( 0.6f )
: state.Status == SyncStatus.LocalOnly ? Color.Yellow.WithAlpha( 0.6f )
: Color.Orange.WithAlpha( 0.6f );
Paint.SetDefaultFont( size: 8 );
Paint.SetPen( summaryColor );
Paint.DrawText( new Rect( pad + 22, y, w - 80, 14 ), state.DiffSummary, TextFlag.LeftCenter );
// Action buttons on the diff line
if ( state.Status != SyncStatus.LocalOnly && state.Status != SyncStatus.MergeAvailable )
{
var btnX = pad + w - 4;
// View Diff button
var diffBtnW = 56f;
var diffBtnH = 16f;
btnX -= diffBtnW;
var diffBtnRect = new Rect( btnX, y - 1, diffBtnW, diffBtnH );
var diffHovered = diffBtnRect.IsInside( _mousePos );
Paint.SetBrush( Color.Orange.WithAlpha( diffHovered ? 0.2f : 0.08f ) );
Paint.SetPen( Color.Orange.WithAlpha( diffHovered ? 0.6f : 0.25f ) );
Paint.DrawRect( diffBtnRect, 3 );
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( Color.Orange.WithAlpha( diffHovered ? 1f : 0.7f ) );
Paint.DrawText( diffBtnRect, "View Diff", TextFlag.Center );
var capturedId = id;
var capturedName = name;
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = diffBtnRect, Id = $"diff_{id}", OnClick = () => OpenDiffView( capturedId, capturedName ) } );
// Merge Meta button (for collections where schema is same but metadata differs)
if ( id.StartsWith( "col_" ) && state.DiffSummary != null && state.DiffSummary.Contains( "schema: identical" ) && state.DiffSummary.Contains( "metadata differs" ) )
{
var mergeBtnW = 68f;
btnX -= mergeBtnW + 4;
var mergeBtnRect = new Rect( btnX, y - 1, mergeBtnW, diffBtnH );
var mergeHovered = mergeBtnRect.IsInside( _mousePos );
Paint.SetBrush( Color.Green.WithAlpha( mergeHovered ? 0.2f : 0.08f ) );
Paint.SetPen( Color.Green.WithAlpha( mergeHovered ? 0.6f : 0.25f ) );
Paint.DrawRect( mergeBtnRect, 3 );
Paint.SetDefaultFont( size: 8, weight: 600 );
Paint.SetPen( Color.Green.WithAlpha( mergeHovered ? 1f : 0.7f ) );
Paint.DrawText( mergeBtnRect, "Merge Meta", TextFlag.Center );
if ( !_busy )
_buttons.Add( new ClickRegion { Rect = mergeBtnRect, Id = $"merge_{id}", OnClick = () => MergeMetadata( capturedId ) } );
}
}
y += 18;
}
y += 1;
}
// ──────────────────────────────────────────────────────
// Data Sources (C# → Collection sync mappings)
// ──────────────────────────────────────────────────────
private void DrawDataSourcesSection( ref float y, float pad, float w )
{
DrawSectionHeader( ref y, pad, w, $"DATA SOURCES ({SyncToolConfig.SyncMappings.Count})" );
foreach ( var mapping in SyncToolConfig.SyncMappings )
{
var status = GetDataSourceStatus( mapping );
var rowH = 28f;
var btnW = 68f;
var btnH = 20f;
var rowRect = new Rect( pad, y, w, rowH );
var hovered = rowRect.IsInside( _mousePos );
if ( hovered )
{
Paint.SetBrush( Color.White.WithAlpha( 0.03f ) );
Paint.SetPen( Color.Transparent );
Paint.DrawRect( rowRect, 3 );
}
// Status icon
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( status.Color );
Paint.DrawText( new Rect( pad + 2, y, 18, rowH ), status.Icon, TextFlag.Center );
// Mapping label
Paint.SetDefaultFont( size: 9 );
Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
var labelSuffix = status.IsStale ? " (stale)" : "";
Paint.DrawText( new Rect( pad + 22, y, w - btnW - 30, rowH ),
$"{mapping.CsFile} → {mapping.Collection}.collection.yml{labelSuffix}", TextFlag.LeftCenter );
// Generate button
if ( status.SourceExists )
{
var btnY = y + ( rowH - btnH ) / 2;
var btnRect = new Rect( pad + w - btnW - 4, btnY, btnW, btnH );
var genId = $"gen_{mapping.Collection}";
var isBusyGen = _generateBusy && _busyItem == genId;
var capturedCollection = mapping.Collection;
DrawSmallButton( btnRect, isBusyGen ? "..." : "Generate", Color.Green, genId,
() => ConfirmDialog.Show(
"Generate Collection Data",
$"This will overwrite {capturedCollection}.collection.yml with data parsed from your C# source files.",
() => _ = RunGenerate( capturedCollection ),
"Local YAML source will be regenerated from C# definitions" ) );
}
y += rowH + 1;
// Description
var detailText = string.IsNullOrEmpty( mapping.Description )
? status.Detail
: $"{status.Label}: {status.Detail} - {mapping.Description}";
if ( !string.IsNullOrEmpty( detailText ) )
{
Paint.SetDefaultFont( size: 8 );
Paint.SetPen( status.IsStale ? Color.Yellow.WithAlpha( 0.75f ) : Color.White.WithAlpha( 0.35f ) );
DrawOneLineText( new Rect( pad + 22, y, w - 30, 14 ), detailText, TextFlag.LeftCenter, mapping.CsFile, $"mapping_{mapping.Collection}" );
y += 16;
}
}
}
private async Task RunGenerate( string collectionFilter )
{
if ( _generateBusy ) return;
_generateBusy = true;
_busyItem = collectionFilter != null ? $"gen_{collectionFilter}" : "generate_all";
SetStatus( "Generating collection data from C# sources..." );
Update();
try
{
var syncPy = SyncToolConfig.Abs( SyncToolConfig.SyncPyPath );
if ( !File.Exists( syncPy ) )
{
ShowGenerateFailure( $"sync.py not found at {SyncToolConfig.SyncPyPath}" );
return;
}
var projectRoot = SyncToolConfig.ProjectRoot;
var args = $"\"{syncPy}\" --project-root \"{projectRoot}\" --generate";
if ( collectionFilter != null )
args += $" --collection \"{collectionFilter}\"";
var psi = new ProcessStartInfo
{
FileName = "python",
Arguments = args,
WorkingDirectory = SyncToolConfig.ProjectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
var process = Process.Start( psi );
if ( process == null )
{
ShowGenerateFailure( "Failed to start Python. Check that Python is installed and on PATH." );
return;
}
var stdout = await Task.Run( () => process.StandardOutput.ReadToEnd() );
var stderr = await Task.Run( () => process.StandardError.ReadToEnd() );
process.WaitForExit( 30000 );
var trimmedStdout = stdout?.Trim();
var trimmedStderr = stderr?.Trim();
// Log output to console, not inline
if ( !string.IsNullOrWhiteSpace( trimmedStdout ) )
Log.Info( $"[SyncTool] sync.py output:\n{trimmedStdout}" );
if ( !string.IsNullOrWhiteSpace( trimmedStderr ) )
Log.Warning( $"[SyncTool] sync.py errors:\n{trimmedStderr}" );
if ( process.ExitCode == 0 )
{
if ( string.IsNullOrWhiteSpace( trimmedStdout ) && string.IsNullOrWhiteSpace( trimmedStderr ) )
{
ShowGenerateFailure(
"sync.py exited with code 0 but produced no output",
$"Command: python {args}\nWorking directory: {SyncToolConfig.ProjectRoot}" );
}
else
{
SetStatus( "Generation complete" );
RefreshFileList();
}
}
else
{
var summary = ExtractGenerateFailureSummary( stdout, stderr, process.ExitCode );
var detail = BuildGenerateFailureDetail( stdout, stderr );
ShowGenerateFailure( summary, detail );
}
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Generate error: {ex.Message}" );
ShowGenerateFailure( $"Generate failed: {ex.Message}" );
}
finally
{
_generateBusy = false;
_busyItem = null;
Update();
}
}
// ──────────────────────────────────────────────────────
// Mouse + Scroll
// ──────────────────────────────────────────────────────
protected override void OnContentMousePress( MouseEvent e )
{
base.OnContentMousePress(e);
if ( _busy ) return;
foreach ( var btn in _buttons )
{
if ( btn.Rect.IsInside( e.LocalPosition ) )
{
btn.OnClick?.Invoke();
return;
}
}
}
protected override void OnContentMouseMove( MouseEvent e )
{
base.OnContentMouseMove(e);
_mousePos = e.LocalPosition;
Update();
}
protected override void OnContentMouseWheel( WheelEvent e )
{
var direction = e.Delta > 0 ? -1 : 1;
_scrollY = Math.Clamp( _scrollY + direction * RowH * 3, 0, MaxScroll );
Update();
e.Accept();
}
protected override void OnContentKeyPress( KeyEvent e )
{
var handled = true;
switch ( e.Key )
{
case KeyCode.Up: _scrollY = Math.Max( 0, _scrollY - RowH ); break;
case KeyCode.Down: _scrollY = Math.Min( MaxScroll, _scrollY + RowH ); break;
case KeyCode.PageUp: _scrollY = Math.Max( 0, _scrollY - RowH * 10 ); break;
case KeyCode.PageDown: _scrollY = Math.Min( MaxScroll, _scrollY + RowH * 10 ); break;
case KeyCode.Home: _scrollY = 0; break;
case KeyCode.End: _scrollY = MaxScroll; break;
default: handled = false; break;
}
if ( handled ) Update();
else base.OnContentKeyPress(e);
}
/// <summary>Scroll to show the bottom of the content.</summary>
private void ScrollToBottom()
{
if ( !IsValid ) return;
_scrollY = MaxScroll;
Update();
}
// ──────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────
private bool HasRemoteDiff( string idPrefix )
{
return _items.Any( kv => kv.Key.StartsWith( idPrefix ) && kv.Value.RemoteDiffers );
}
private SyncStatus GetItemStatus( string id )
{
return _items.TryGetValue( id, out var s ) ? s.Status : SyncStatus.Unknown;
}
/// <summary>
/// Draws a row in the CHANGES section — reconstructs push/pull actions from the item id prefix.
/// </summary>
private void DrawChangedItemRow( ref float y, float pad, float w, string id )
{
string name, info;
Action pushAction, pullAction;
Action testAction = null;
bool deprecated = false;
if ( id.StartsWith( "ep_" ) )
{
var slug = id.Substring( 3 );
var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
var hasLocal = localFile != null;
deprecated = hasLocal && IsEndpointDeprecated( localFile );
name = $"{slug}.endpoint.yml";
info = deprecated ? $"{GetEndpointInfo( localFile )} - deprecated, ignored by sync" : hasLocal ? GetEndpointInfo( localFile ) : "remote only";
var capturedId = id;
var capturedSlug = slug;
pushAction = hasLocal && !deprecated ? () => PushItem( capturedId ) : null;
pullAction = deprecated ? null : () => PullItem( capturedId );
testAction = deprecated ? null : () => TestResultsWindow.OpenAndRun( capturedSlug, _publishTarget );
}
else if ( id.StartsWith( "col_" ) )
{
var colName = id.Substring( 4 );
var hasLocal = _collectionFiles.Any( f => ResourceIdFromFile( f, "collection" ) == colName );
name = $"{colName}.collection.yml";
info = hasLocal ? "schema" : "remote only";
var capturedId = id;
pushAction = hasLocal ? () => PushItem( capturedId ) : null;
pullAction = () => PullItem( capturedId );
}
else if ( id.StartsWith( "wf_" ) )
{
var wfId = id.Substring( 3 );
var localFile = SyncToolConfig.FindWorkflowFileById( wfId );
var hasLocal = localFile != null;
name = $"{wfId}.workflow.yml";
info = hasLocal ? GetWorkflowInfo( localFile ) : "remote only";
var capturedId = id;
pushAction = hasLocal ? () => PushItem( capturedId ) : null;
pullAction = () => PullItem( capturedId );
}
else return;
DrawResourceRow( ref y, pad, w, name, info, id, pushAction, pullAction, testAction, deprecated,
staged: id.StartsWith( "ep_" ) && IsEndpointStaged( id.Substring( 3 ) ) );
}
private List<string> GetRemoteEndpointSlugs()
{
if ( !_remoteEndpoints.HasValue ) return new List<string>();
var data = _remoteEndpoints.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind != JsonValueKind.Array ) return new List<string>();
var slugs = new List<string>();
foreach ( var ep in data.EnumerateArray() )
{
if ( SyncToolConfig.IsEndpointDeprecated( ep ) )
continue;
var slug = GetRemoteEndpointSlug( ep );
if ( !string.IsNullOrEmpty( slug ) )
slugs.Add( slug );
}
return slugs;
}
private static string GetRemoteEndpointSlug( JsonElement ep )
{
if ( ep.TryGetProperty( "slug", out var s ) && s.ValueKind == JsonValueKind.String )
return s.GetString();
var local = SyncToolTransforms.ServerEndpointToLocal( ep );
return local.TryGetValue( "slug", out var value ) ? value?.ToString() : "";
}
private List<string> GetRemoteCollectionNames()
{
if ( !_remoteCollections.HasValue ) return new List<string>();
var collections = SyncToolTransforms.ServerToCollections( _remoteCollections.Value );
return collections.Select( c => c.Name ).ToList();
}
private List<string> GetRemoteWorkflowIds()
{
if ( !_remoteWorkflows.HasValue ) return new List<string>();
var data = _remoteWorkflows.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind != JsonValueKind.Array ) return new List<string>();
var ids = new List<string>();
foreach ( var wf in data.EnumerateArray() )
{
if ( wf.TryGetProperty( "id", out var id ) )
ids.Add( id.GetString() );
}
return ids;
}
private string GetWorkflowInfo( string filePath )
{
try
{
if ( !TryReadLocalResourceFile( filePath, "workflow", out var wf ) )
return "";
return wf.TryGetProperty( "name", out var n ) ? n.GetString() : "workflow";
}
catch { return ""; }
}
private string GetEndpointInfo( string filePath )
{
try
{
if ( !TryReadLocalResourceFile( filePath, "endpoint", out var ep ) )
return "";
return ep.TryGetProperty( "method", out var m ) ? m.GetString() : "POST";
}
catch { return ""; }
}
private string[] GetActiveEndpointFiles()
{
return _endpointFiles.Where( f => !IsEndpointDeprecated( f ) ).ToArray();
}
private bool IsEndpointDeprecated( string filePath )
{
try
{
if ( !TryReadLocalResourceFile( filePath, "endpoint", out var ep ) )
return false;
return SyncToolConfig.IsEndpointDeprecated( ep );
}
catch { return false; }
}
private bool TryReadLocalResourceFile( string filePath, string kind, out JsonElement resource )
{
resource = default;
return SyncToolConfig.TryLoadSourceCanonicalResource( kind, filePath, out resource );
}
private void SetItemState( string id, string result = null, bool? remoteDiffers = null,
string diffSummary = null, string localJson = null, string remoteJson = null, SyncStatus? status = null )
{
_items.TryGetValue( id, out var state );
if ( result != null ) state.SyncResult = result;
if ( remoteDiffers.HasValue ) state.RemoteDiffers = remoteDiffers.Value;
if ( diffSummary != null ) state.DiffSummary = diffSummary;
if ( localJson != null )
{
state.LocalJson = localJson;
state.LocalYaml = PreviewYaml( id, localJson );
}
if ( remoteJson != null )
{
state.RemoteJson = remoteJson;
state.RemoteYaml = PreviewYaml( id, remoteJson );
}
if ( status.HasValue ) state.Status = status.Value;
_items[id] = state;
}
/// <summary>Display-only YAML for a row. An endpoint shows its script as its own block, the way a pull writes it.</summary>
private static string PreviewYaml( string id, string json ) =>
id.StartsWith( "ep_", StringComparison.Ordinal )
? SyncToolPullWriter.EndpointPreviewText( id[3..], json )
: SyncToolYamlRenderer.RenderFromJson( json );
private void ClearAllRemoteDiffs()
{
foreach ( var key in _items.Keys.ToList() )
{
var s = _items[key];
s.RemoteDiffers = false;
_items[key] = s;
}
}
private string[] GetRemoteSemanticsIds()
{
return _items
.Where( x => x.Value.Status == SyncStatus.MergeAvailable && !string.IsNullOrEmpty( x.Value.RemoteJson ) && !IsGeneratedMappedCollectionId( x.Key ) )
.Select( x => x.Key )
.OrderBy( x => x )
.ToArray();
}
private int GetRemoteSemanticsCount()
{
return GetRemoteSemanticsIds().Length;
}
// ──────────────────────────────────────────────────────
// Check for Updates (compare local vs remote)
// ──────────────────────────────────────────────────────
private async Task CheckForUpdates()
{
if ( _busy || !SyncToolConfig.IsValid ) return;
_scrollY = 0;
_busy = true;
_busyItem = "check_updates";
_status = "Checking remote for changes...";
_items.Clear();
_syncLog.Clear();
RefreshFileList();
Update();
try
{
await DoCheckForUpdates();
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Check for updates failed: {ex.Message}" );
_status = $"Check failed: {ex.Message}";
}
finally
{
_busy = false;
_busyItem = null;
_ = SyncLocalPackageInfoAsync();
Update();
}
}
private async Task DoCheckForUpdates()
{
_items.Clear();
var diffs = 0;
var remoteSemanticsCount = 0;
var localOnlyCount = 0;
// ── Load local files via SyncToolConfig (uses System.IO with correct project root) ──
var localEndpoints = SyncToolConfig.LoadEndpoints();
var localCollections = SyncToolConfig.LoadCollections();
var localWorkflows = SyncToolConfig.LoadWorkflows();
var localEpBySlug = new Dictionary<string, JsonElement>();
var localEpSourceTextBySlug = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var ep in localEndpoints )
{
var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
if ( !string.IsNullOrEmpty( slug ) )
{
localEpBySlug[slug] = ep;
if ( SyncToolTransforms.TryGetSourceText( ep, out var localSourceText ) )
localEpSourceTextBySlug[slug] = NormalizeSourceTextForVerification( localSourceText );
}
}
// Also track deprecated local files so the diff loop can distinguish
// "remote endpoint has no local counterpart" (truly remote-only) from
// "remote endpoint has a local counterpart flagged deprecated" (intentionally ignored).
var localDeprecatedSlugs = new HashSet<string>();
foreach ( var ep in SyncToolConfig.LoadEndpoints( includeDeprecated: true ) )
{
if ( !SyncToolConfig.IsEndpointDeprecated( ep ) ) continue;
var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
if ( !string.IsNullOrEmpty( slug ) ) localDeprecatedSlugs.Add( slug );
}
var localColByName = new Dictionary<string, string>();
var localColSourceTextByName = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var (name, data) in localCollections )
{
var stripped = SyncToolTransforms.StripServerManagedFields( data );
localColByName[name] = JsonSerializer.Serialize( stripped, new JsonSerializerOptions { WriteIndented = true } );
if ( data.TryGetValue( "sourceText", out var sourceText ) && sourceText is string source && !string.IsNullOrWhiteSpace( source ) )
localColSourceTextByName[name] = NormalizeSourceTextForVerification( source );
}
var localWfById = new Dictionary<string, JsonElement>();
var localWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var wf in localWorkflows )
{
var wfId = wf.TryGetProperty( "id", out var id ) ? id.GetString() : "";
if ( !string.IsNullOrEmpty( wfId ) )
{
localWfById[wfId] = wf;
if ( SyncToolTransforms.TryGetSourceText( wf, out var localSourceText ) )
localWfSourceTextById[wfId] = NormalizeSourceTextForVerification( localSourceText );
}
}
// ── Fetch endpoints, collections, workflows in parallel ──
// Always read the selected publish target so Live checks do not accidentally
// compare against staged overrides, and Staged/Main checks see next-release data.
var remoteEpsTask = SyncToolApi.GetEndpointsForPublishTarget( _publishTarget );
var remoteColsTask = SyncToolApi.GetCollectionsForPublishTarget( _publishTarget );
var remoteWfsTask = SyncToolApi.GetWorkflows();
var projectSettingsTask = SyncToolApi.GetProjectSettings();
await Task.WhenAll( remoteEpsTask, remoteColsTask, remoteWfsTask, projectSettingsTask );
_remoteEndpoints = await remoteEpsTask;
_remoteCollections = await remoteColsTask;
_remoteWorkflows = await remoteWfsTask;
var projectSettings = await projectSettingsTask;
if ( projectSettings.HasValue )
SyncToolConfig.TryApplyProjectSecuritySettings( projectSettings.Value );
if ( _remoteEndpoints.HasValue )
SyncToolConfig.TryApplyProjectSecuritySettings( _remoteEndpoints.Value );
if ( _remoteCollections.HasValue )
SyncToolConfig.TryApplyProjectSecuritySettings( _remoteCollections.Value );
if ( _remoteWorkflows.HasValue )
SyncToolConfig.TryApplyProjectSecuritySettings( _remoteWorkflows.Value );
// ── Detect package/revision info ──
try
{
await NetworkStoragePackageInfo.DetectAsync();
_packageInfoDetected = NetworkStoragePackageInfo.IsDetected;
_packageIdent = NetworkStoragePackageInfo.PackageIdent;
_currentRevisionId = NetworkStoragePackageInfo.CurrentRevisionId;
_publishStatus = NetworkStoragePackageInfo.PublishStatus;
if ( _packageInfoDetected )
{
var syncResp = await SyncToolApi.SyncPackageInfo( NetworkStoragePackageInfo.BuildSyncPayload() );
}
await RefreshServerPackageStateAsync();
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Package detection failed: {ex.Message}" );
_packageInfoDetected = false;
}
if ( !_remoteEndpoints.HasValue )
{
_status = "Failed to fetch endpoints from server — check Base URL and credentials";
_hasCheckedRemote = true;
return;
}
if ( !_remoteCollections.HasValue )
{
_status = "Failed to fetch collections from server — check Base URL and credentials";
_hasCheckedRemote = true;
return;
}
// ── Process endpoints ──
var remoteSlugs = new HashSet<string>();
{
var data = _remoteEndpoints.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind == JsonValueKind.Array )
{
foreach ( var ep in data.EnumerateArray() )
{
if ( SyncToolConfig.IsEndpointDeprecated( ep ) )
continue;
var slug = GetRemoteEndpointSlug( ep );
if ( string.IsNullOrEmpty( slug ) ) continue;
remoteSlugs.Add( slug );
var id = $"ep_{slug}";
var remoteLocal = SyncToolTransforms.ServerEndpointToLocal( ep );
var remoteJson = JsonSerializer.Serialize( remoteLocal, new JsonSerializerOptions { WriteIndented = true } );
if ( !localEpBySlug.TryGetValue( slug, out var localEp ) )
{
if ( localDeprecatedSlugs.Contains( slug ) )
{
// Local file exists but is flagged `_deprecated` — sync intentionally
// ignores it. Treat as in-sync from a diff perspective so the row
// isn't flagged "Remote only" every check; the main endpoints list
// still labels it "deprecated, ignored by sync".
SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
diffSummary: "Deprecated locally — ignored by sync",
localJson: "", remoteJson: PrettyJson( remoteJson ) );
}
else
{
SetItemState( id, remoteDiffers: true, status: SyncStatus.RemoteOnly,
diffSummary: "Remote only — not in local files",
localJson: "", remoteJson: PrettyJson( remoteJson ) );
diffs++;
}
}
else
{
// Transform local same as remote to strip server-managed fields.
// If both sides expose identical sourceText, trust the backend compiler's
// canonicalization instead of the editor's lightweight YAML parser.
var localTransformed = SyncToolTransforms.ServerEndpointToLocal( localEp );
var localJson = JsonSerializer.Serialize( localTransformed, new JsonSerializerOptions { WriteIndented = true } );
var sourceTextMatches = localEpSourceTextBySlug.TryGetValue( slug, out var localSourceText )
&& SyncToolTransforms.TryGetSourceText( ep, out var remoteSourceText )
&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );
// The handler script is compared on its own. A matching remote sourceText must not hide a script
// changed on the live definition, because that sourceText can be stale.
var scriptMatches = SyncToolTransforms.EndpointScriptsMatch( localEp, ep );
var differs = !scriptMatches || ( !sourceTextMatches && NormalizeJson( remoteJson ) != NormalizeJson( localJson ) );
if ( differs )
{
var localPretty = PrettyJson( localJson );
var remotePretty = PrettyJson( remoteJson );
var classification = ClassifyRemoteDifference( id, localPretty, remotePretty );
SetItemState( id, remoteDiffers: true, status: classification.Status,
diffSummary: classification.Summary,
localJson: localPretty, remoteJson: remotePretty );
if ( classification.Status == SyncStatus.MergeAvailable ) remoteSemanticsCount++;
else diffs++;
}
else
{
SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
localJson: PrettyJson( localJson ), remoteJson: PrettyJson( remoteJson ) );
}
}
}
}
}
foreach ( var slug in localEpBySlug.Keys )
{
if ( !remoteSlugs.Contains( slug ) )
{
var id = $"ep_{slug}";
var localJson = JsonSerializer.Serialize( localEpBySlug[slug], new JsonSerializerOptions { WriteIndented = true } );
SetItemState( id, remoteDiffers: false, status: SyncStatus.LocalOnly,
diffSummary: "Local only — not pushed to server",
localJson: PrettyJson( localJson ), remoteJson: "" );
localOnlyCount++;
}
}
// ── Check collections ──
var remoteColNames = new HashSet<string>();
{
var remoteCollections = _remoteCollections.Value;
if ( remoteCollections.TryGetProperty( "data", out var remoteCollectionData ) ) remoteCollections = remoteCollectionData;
if ( remoteCollections.ValueKind == JsonValueKind.Array )
{
foreach ( var remoteCollection in remoteCollections.EnumerateArray() )
{
var remoteLocal = SyncToolTransforms.ServerCollectionToLocal( remoteCollection );
var colName = remoteLocal.TryGetValue( "name", out var nameValue ) ? nameValue?.ToString() ?? "unknown" : "unknown";
remoteColNames.Add( colName );
var id = $"col_{colName}";
var remoteJson = JsonSerializer.Serialize( remoteLocal, new JsonSerializerOptions { WriteIndented = true } );
if ( !localColByName.TryGetValue( colName, out var localJson ) )
{
SetItemState( id, remoteDiffers: true, status: SyncStatus.RemoteOnly,
diffSummary: "Remote only — no local file",
localJson: "", remoteJson: PrettyJson( remoteJson ) );
diffs++;
}
else
{
var sourceTextMatches = localColSourceTextByName.TryGetValue( colName, out var localSourceText )
&& SyncToolTransforms.TryGetSourceText( remoteCollection, out var remoteSourceText )
&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );
var differs = !sourceTextMatches && !CollectionSemanticsMatch( localJson, remoteJson );
if ( differs )
{
var localPretty = PrettyJson( localJson );
var remotePretty = PrettyJson( remoteJson );
var classification = ClassifyRemoteDifference( id, localPretty, remotePretty );
if ( IsGeneratedMappedCollectionId( id ) && classification.Analysis.IsRemoteAdditiveOnly )
{
SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
diffSummary: "Generated from C# — remote semantics ignored",
localJson: localPretty, remoteJson: remotePretty );
}
else
{
SetItemState( id, remoteDiffers: true, status: classification.Status,
diffSummary: classification.Summary,
localJson: localPretty, remoteJson: remotePretty );
if ( classification.Status == SyncStatus.MergeAvailable ) remoteSemanticsCount++;
else diffs++;
}
}
else
{
SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
localJson: PrettyJson( localJson ), remoteJson: PrettyJson( remoteJson ) );
}
}
}
}
}
foreach ( var colName in localColByName.Keys )
{
if ( !remoteColNames.Contains( colName ) )
{
var id = $"col_{colName}";
SetItemState( id, remoteDiffers: false, status: SyncStatus.LocalOnly,
diffSummary: "Local only — not pushed to server",
localJson: PrettyJson( localColByName[colName] ), remoteJson: "" );
localOnlyCount++;
}
}
// ── Check workflows ──
var remoteWfIds = new HashSet<string>();
if ( _remoteWorkflows.HasValue )
{
var remoteWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
var remoteWorkflowData = _remoteWorkflows.Value;
if ( remoteWorkflowData.TryGetProperty( "data", out var remoteWorkflowArray ) ) remoteWorkflowData = remoteWorkflowArray;
if ( remoteWorkflowData.ValueKind == JsonValueKind.Array )
{
foreach ( var remoteWorkflow in remoteWorkflowData.EnumerateArray() )
{
var remoteLocal = SyncToolTransforms.ServerWorkflowToLocal( remoteWorkflow );
var remoteId = remoteLocal.TryGetValue( "id", out var value ) ? value?.ToString() : null;
if ( !string.IsNullOrEmpty( remoteId ) && SyncToolTransforms.TryGetSourceText( remoteWorkflow, out var remoteSourceText ) )
remoteWfSourceTextById[remoteId] = NormalizeSourceTextForVerification( remoteSourceText );
}
}
var workflows = SyncToolTransforms.ServerToWorkflows( _remoteWorkflows.Value );
foreach ( var (wfId, remoteLocal) in workflows )
{
remoteWfIds.Add( wfId );
var id = $"wf_{wfId}";
var remoteJson = JsonSerializer.Serialize( remoteLocal, new JsonSerializerOptions { WriteIndented = true } );
if ( !localWfById.TryGetValue( wfId, out var localWf ) )
{
SetItemState( id, remoteDiffers: true, status: SyncStatus.RemoteOnly,
diffSummary: "Remote only — no local file",
localJson: "", remoteJson: PrettyJson( remoteJson ) );
diffs++;
}
else
{
// Transform local same as remote to strip server-managed fields
var localTransformed = SyncToolTransforms.ServerWorkflowToLocal( localWf );
var localJson = JsonSerializer.Serialize( localTransformed, new JsonSerializerOptions { WriteIndented = true } );
var sourceTextMatches = localWfSourceTextById.TryGetValue( wfId, out var localSourceText )
&& remoteWfSourceTextById.TryGetValue( wfId, out var remoteSourceText )
&& localSourceText == remoteSourceText;
var normRemote = NormalizeJson( remoteJson );
var normLocal = NormalizeJson( localJson );
var differs = !sourceTextMatches && normRemote != normLocal;
(SyncStatus Status, string Summary, JsonDiffUtilities.ComparisonResult Analysis) classification = default;
LogDiffResult( wfId, "workflow", localJson, remoteJson, differs, classification );
if ( differs )
{
var localPretty = PrettyJson( localJson );
var remotePretty = PrettyJson( remoteJson );
classification = ClassifyRemoteDifference( id, localPretty, remotePretty );
SetItemState( id, remoteDiffers: true, status: classification.Status,
diffSummary: classification.Summary,
localJson: localPretty, remoteJson: remotePretty );
if ( classification.Status == SyncStatus.MergeAvailable ) remoteSemanticsCount++;
else diffs++;
}
else
{
SetItemState( id, remoteDiffers: false, status: SyncStatus.InSync,
localJson: PrettyJson( localJson ), remoteJson: PrettyJson( remoteJson ) );
}
}
}
}
foreach ( var wfId in localWfById.Keys )
{
if ( !remoteWfIds.Contains( wfId ) )
{
var id = $"wf_{wfId}";
var localJson = JsonSerializer.Serialize( localWfById[wfId], new JsonSerializerOptions { WriteIndented = true } );
SetItemState( id, remoteDiffers: false, status: SyncStatus.LocalOnly,
diffSummary: "Local only — not pushed to server",
localJson: PrettyJson( localJson ), remoteJson: "" );
localOnlyCount++;
}
}
_hasCheckedRemote = true;
var parts = new List<string>();
if ( diffs > 0 ) parts.Add( $"{diffs} remote diff(s)" );
if ( remoteSemanticsCount > 0 ) parts.Add( $"{remoteSemanticsCount} remote semantics" );
if ( localOnlyCount > 0 ) parts.Add( $"{localOnlyCount} local only" );
_status = parts.Count > 0
? string.Join( ", ", parts )
: "Everything is in sync";
}
private (SyncStatus Status, string Summary, JsonDiffUtilities.ComparisonResult Analysis) ClassifyRemoteDifference(
string id, string localJson, string remoteJson )
{
var analysis = JsonDiffUtilities.Analyze( localJson, remoteJson );
if ( analysis.IsRemoteAdditiveOnly )
return (SyncStatus.MergeAvailable, BuildRemoteSemanticsSummary( analysis ), analysis);
var lineSummary = JsonDiffUtilities.SummarizeLineDifferences( analysis.LineCounts );
var detail = BuildResourceDiffDetail( id, localJson, remoteJson );
return (SyncStatus.Differs, CombineDiffSummary( lineSummary, detail ), analysis);
}
private string BuildResourceDiffDetail( string id, string localJson, string remoteJson )
{
if ( id.StartsWith( "ep_" ) )
return DiffEndpoint( localJson, remoteJson, id[3..] );
if ( id.StartsWith( "col_" ) )
return DiffCollectionSchema( localJson, remoteJson );
return null;
}
private static string BuildRemoteSemanticsSummary( JsonDiffUtilities.ComparisonResult analysis )
{
var lineSummary = analysis.LineCounts.HasChanges
? JsonDiffUtilities.SummarizeLineDifferences( analysis.LineCounts )
: $"Remote added {analysis.Added.Count} field{Plural( analysis.Added.Count )}";
var preview = JsonDiffUtilities.PreviewPaths( analysis.Added );
return string.IsNullOrWhiteSpace( preview )
? $"{lineSummary} | Pull Remote Semantics"
: $"{lineSummary} | Pull Remote Semantics: {preview}";
}
private static string BuildRemoteSemanticsLogDetail( JsonDiffUtilities.ComparisonResult analysis )
{
var preview = JsonDiffUtilities.PreviewPaths( analysis.Added );
var count = analysis.Added.Count;
var countText = $"{count} remote field{Plural( count )}";
return string.IsNullOrWhiteSpace( preview )
? $"Remote semantics available - {countText}"
: $"Remote semantics available - {countText}: {preview}";
}
private static string CombineDiffSummary( string lineSummary, string detail )
{
if ( string.IsNullOrWhiteSpace( detail ) )
return lineSummary;
if ( detail.Equals( "Field ordering differs (content identical)", StringComparison.OrdinalIgnoreCase ) )
return lineSummary;
if ( detail.StartsWith( "Content differs", StringComparison.OrdinalIgnoreCase ) )
return lineSummary;
if ( string.IsNullOrWhiteSpace( lineSummary ) )
return detail;
return $"{lineSummary} | {detail}";
}
private static string Plural( int count )
{
return count == 1 ? "" : "s";
}
private static string DescribeSyncItem( string id )
{
if ( id.StartsWith( "ep_" ) ) return $"{id[3..]}.endpoint.yml (endpoint)";
if ( id.StartsWith( "col_" ) ) return $"{id[4..]}.collection.yml (collection)";
if ( id.StartsWith( "wf_" ) ) return $"{id[3..]}.workflow.yml (workflow)";
if ( id.StartsWith( "test_" ) ) return $"{id[5..]}.test.yml (test)";
return id;
}
// ──────────────────────────────────────────────────────
// Push (with remote-newer warning)
// ──────────────────────────────────────────────────────
private async Task PushAll()
{
if ( _busy || !SyncToolConfig.IsValid ) return;
if ( EditorCompatibility.PushBlocked )
{
MessageDialog.Show( "Push All refused", EditorCompatibility.PushBlockedMessage, EditorCompatibility.PushBlockedSteps );
return;
}
_scrollY = 0;
_busy = true;
_busyItem = "push_all";
_status = "Pushing all resources...";
_syncLog.Clear();
foreach ( var k in _items.Keys.ToList() ) SetItemState( k, result: null );
Update();
var activeEndpointFiles = GetActiveEndpointFiles();
// Script-file problems stop the whole push before anything is sent; rows are set in ReportPushBlocked.
if ( !EndpointScriptsReadyForPush() )
{
_busy = false;
_busyItem = null;
ScrollToBottom();
Update();
return;
}
var pushAllPayload = BuildPushAllPayload( out var hasAnyPushResource );
if ( !hasAnyPushResource || !await RunPreflightOrOfferFix( pushAllPayload, () => _ = PushAll() ) )
{
_busy = false;
_busyItem = null;
ScrollToBottom();
Update();
return;
}
// ── Load local files for pre-push comparison ──
// Apply same transform as remote so id/createdAt are stripped from both sides
var localEndpoints = SyncToolConfig.LoadEndpoints();
var localEpBySlug = new Dictionary<string, string>();
var localEpSourceTextBySlug = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var ep in localEndpoints )
{
var slug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
if ( !string.IsNullOrEmpty( slug ) )
{
var localTransformed = SyncToolTransforms.ServerEndpointToLocal( ep );
localEpBySlug[slug] = NormalizeJson( JsonSerializer.Serialize( localTransformed ) );
if ( SyncToolTransforms.TryGetSourceText( ep, out var localSourceText ) )
localEpSourceTextBySlug[slug] = NormalizeSourceTextForVerification( localSourceText );
}
}
// ── Push all resources in single batch (falls back to individual if batch not supported) ──
_busyItem = "push_all";
_status = "Pushing all resources...";
Update();
var (epOk, colOk, wfOk) = await DoPushAllBatched();
// ── Log push results ──
if ( activeEndpointFiles.Length > 0 )
{
foreach ( var f in activeEndpointFiles )
{
var slug = ResourceIdFromFile( f, "endpoint" );
var failDetail = GetPushFailDetail( "endpoints", slug );
var detail = epOk ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : failDetail;
SetItemState( $"ep_{slug}", result: epOk ? "OK" : "FAIL",
remoteDiffers: false, diffSummary: "", status: epOk ? SyncStatus.InSync : null );
_syncLog.Add( new SyncLogEntry { Name = $"{slug}.endpoint.yml", Type = "Endpoint", Ok = epOk, Detail = detail } );
}
}
if ( _collectionFiles.Length > 0 )
{
foreach ( var f in _collectionFiles )
{
var name = ResourceIdFromFile( f, "collection" );
var failDetail = GetPushFailDetail( "collections", name );
var detail = colOk ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : failDetail;
SetItemState( $"col_{name}", result: colOk ? "OK" : "FAIL",
remoteDiffers: false, diffSummary: "", status: colOk ? SyncStatus.InSync : null );
_syncLog.Add( new SyncLogEntry { Name = $"{name}.collection.yml", Type = "Collection", Ok = colOk, Detail = detail } );
}
}
if ( _workflowFiles.Length > 0 )
{
foreach ( var f in _workflowFiles )
{
var name = ResourceIdFromFile( f, "workflow" );
var failDetail = GetPushFailDetail( "workflows", name );
var detail = wfOk ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : failDetail;
SetItemState( $"wf_{name}", result: wfOk ? "OK" : "FAIL",
remoteDiffers: false, diffSummary: "", status: wfOk ? SyncStatus.InSync : null );
_syncLog.Add( new SyncLogEntry { Name = $"{name}.workflow.yml", Type = "Workflow", Ok = wfOk, Detail = detail } );
}
}
ScrollToBottom();
// ── Auto-verify: re-fetch remote and compare to local ──
_busyItem = "verify";
_status = "Verifying remote matches local...";
Update();
await VerifyPushResults( localEpBySlug, localEpSourceTextBySlug, _publishTarget );
// ── Auto-generate typed C# files from the pushed schemas ──
_busyItem = "codegen";
_status = "Generating typed C# code...";
Update();
try
{
var filesWritten = CodeGenerator.Generate();
_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = true, Detail = $"{filesWritten} files written to Code/Data/NetworkStorage/" } );
}
catch ( Exception ex )
{
_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = false, Detail = ex.Message } );
}
// Invalidate cached remote data — next check will fetch fresh
// Sync local package info after push so backend has latest revision
await SyncLocalPackageInfoAsync();
ClearAllRemoteDiffs();
_remoteEndpoints = null;
_remoteCollections = null;
_remoteWorkflows = null;
_hasCheckedRemote = false;
var okCount = _syncLog.Count( e => e.Ok && IsResourceSyncLog( e ) );
var failCount = _syncLog.Count( e => !e.Ok && IsResourceSyncLog( e ) && !IsBlockedLog( e ) );
var blockedCount = _syncLog.Count( e => IsBlockedLog( e ) );
var mismatchCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Mismatch" ) );
var verifiedCount = _syncLog.Count( e => IsResourceSyncLog( e ) && e.Detail != null && e.Detail.Contains( "Verified" ) );
var mergeCount = _syncLog.Count( e => e.Detail != null && e.Detail.Contains( "Remote semantics available" ) );
if ( mismatchCount > 0 )
ShowVerificationMismatchWindow( mismatchCount );
if ( mergeCount > 0 && mismatchCount == 0 && failCount == 0 && blockedCount == 0 )
_status = $"Pushed OK - {mergeCount} item(s) can pull remote semantics";
else if ( mismatchCount > 0 )
_status = $"Done: {okCount} pushed, {mismatchCount} mismatch(es) — check diffs";
else if ( failCount > 0 || blockedCount > 0 )
_status = $"Done: {okCount} pushed, {failCount} failed" + ( blockedCount > 0 ? $", {blockedCount} not pushed" : "" );
else
_status = $"All synced and verified ({verifiedCount} resources)";
_busy = false;
_busyItem = null;
// Sync local package info so backend has the latest revision
_ = SyncLocalPackageInfoAsync();
ScrollToBottom();
}
/// <summary>
/// After pushing, re-fetch remote data and compare each resource to local.
/// Updates sync log entries with "Verified ✓" or "Mismatch — see diff".
/// </summary>
private async Task VerifyPushResults( Dictionary<string, string> localEpBySlug, Dictionary<string, string> localEpSourceTextBySlug, string publishTarget )
{
try
{
// Re-load local files fresh, stripping server-managed fields for fair comparison
var localCollections = SyncToolConfig.LoadCollections();
var localColByName = new Dictionary<string, string>();
var localColSourceTextByName = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var (name, data) in localCollections )
{
var stripped = SyncToolTransforms.StripServerManagedFields( data );
localColByName[name] = NormalizeJson( JsonSerializer.Serialize( stripped, new JsonSerializerOptions { WriteIndented = true } ) );
if ( data.TryGetValue( "sourceText", out var sourceText ) && sourceText is string source && !string.IsNullOrWhiteSpace( source ) )
localColSourceTextByName[name] = NormalizeSourceTextForVerification( source );
}
var localWorkflows = SyncToolConfig.LoadWorkflows();
var localWfById = new Dictionary<string, string>();
var localWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
foreach ( var wf in localWorkflows )
{
var wfId = wf.TryGetProperty( "id", out var id ) ? id.GetString() : "";
if ( !string.IsNullOrEmpty( wfId ) )
{
var localTransformed = SyncToolTransforms.ServerWorkflowToLocal( wf );
localWfById[wfId] = NormalizeJson( JsonSerializer.Serialize( localTransformed ) );
if ( SyncToolTransforms.TryGetSourceText( wf, out var localSourceText ) )
localWfSourceTextById[wfId] = NormalizeSourceTextForVerification( localSourceText );
}
}
// Fetch all 3 resource types in parallel
var remoteEpsTask = SyncToolApi.GetEndpointsForPublishTarget( publishTarget );
var remoteColsTask = SyncToolApi.GetCollectionsForPublishTarget( publishTarget );
var remoteWfsTask = SyncToolApi.GetWorkflows();
await Task.WhenAll( remoteEpsTask, remoteColsTask, remoteWfsTask );
var remoteEps = await remoteEpsTask;
var remoteCols = await remoteColsTask;
var remoteWfs = await remoteWfsTask;
// Process endpoint results
if ( remoteEps.HasValue )
{
var data = remoteEps.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind == JsonValueKind.Array )
{
foreach ( var ep in data.EnumerateArray() )
{
var slug = GetRemoteEndpointSlug( ep );
if ( string.IsNullOrEmpty( slug ) ) continue;
var remoteLocal = SyncToolTransforms.ServerEndpointToLocal( ep );
var remoteNorm = NormalizeJson( JsonSerializer.Serialize( remoteLocal ) );
var logIdx = _syncLog.FindIndex( e => e.Name == $"{slug}.endpoint.yml" && e.Type == "Endpoint" );
if ( logIdx < 0 ) continue;
var entry = _syncLog[logIdx];
if ( !entry.Ok ) continue; // Skip failed pushes
var sourceTextMatches = localEpSourceTextBySlug.TryGetValue( slug, out var localSourceText )
&& SyncToolTransforms.TryGetSourceText( ep, out var remoteSourceText )
&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );
if ( sourceTextMatches || (localEpBySlug.TryGetValue( slug, out var localNorm ) && localNorm == remoteNorm) )
{
entry.Detail = sourceTextMatches ? "Verified source ✓" : "Verified ✓";
}
else
{
var eid = $"ep_{slug}";
var localPretty = localEpBySlug.TryGetValue( slug, out var lj ) ? PrettyJson( lj ) : "{}";
var remotePretty = PrettyJson( JsonSerializer.Serialize( remoteLocal ) );
var classification = ClassifyRemoteDifference( eid, localPretty, remotePretty );
entry.Detail = classification.Status == SyncStatus.MergeAvailable
? BuildRemoteSemanticsLogDetail( classification.Analysis )
: $"Mismatch - {classification.Summary}";
entry.Ok = classification.Status == SyncStatus.MergeAvailable;
SetItemState( eid, remoteDiffers: true, status: classification.Status,
diffSummary: classification.Summary,
localJson: localPretty, remoteJson: remotePretty );
}
_syncLog[logIdx] = entry;
}
}
}
// Process collection results
if ( remoteCols.HasValue )
{
var remoteCollections = remoteCols.Value;
if ( remoteCollections.TryGetProperty( "data", out var remoteCollectionData ) ) remoteCollections = remoteCollectionData;
if ( remoteCollections.ValueKind == JsonValueKind.Array )
{
foreach ( var remoteCollection in remoteCollections.EnumerateArray() )
{
var remoteLocal = SyncToolTransforms.ServerCollectionToLocal( remoteCollection );
var colName = remoteLocal.TryGetValue( "name", out var nameValue ) ? nameValue?.ToString() ?? "unknown" : "unknown";
var remoteNorm = NormalizeJson( JsonSerializer.Serialize( remoteLocal ) );
var logIdx = _syncLog.FindIndex( e => e.Name == $"{colName}.collection.yml" && e.Type == "Collection" );
if ( logIdx < 0 ) continue;
var entry = _syncLog[logIdx];
if ( !entry.Ok ) continue;
var sourceTextMatches = localColSourceTextByName.TryGetValue( colName, out var localSourceText )
&& SyncToolTransforms.TryGetSourceText( remoteCollection, out var remoteSourceText )
&& localSourceText == NormalizeSourceTextForVerification( remoteSourceText );
if ( sourceTextMatches || (localColByName.TryGetValue( colName, out var localNorm ) && (localNorm == remoteNorm || CollectionSemanticsMatch( localNorm, remoteNorm ))) )
{
entry.Detail = sourceTextMatches ? "Verified source ✓" : "Verified ✓";
}
else
{
var cid = $"col_{colName}";
var localPretty = PrettyJson( localColByName.GetValueOrDefault( colName, "{}" ) );
var remotePretty = PrettyJson( JsonSerializer.Serialize( remoteLocal ) );
var classification = ClassifyRemoteDifference( cid, localPretty, remotePretty );
if ( IsGeneratedMappedCollectionId( cid ) && classification.Analysis.IsRemoteAdditiveOnly )
{
entry.Detail = "Verified generated C# source ✓ (remote semantics ignored)";
entry.Ok = true;
SetItemState( cid, remoteDiffers: false, status: SyncStatus.InSync,
diffSummary: "Generated from C# — remote semantics ignored",
localJson: localPretty, remoteJson: remotePretty );
}
else
{
entry.Detail = classification.Status == SyncStatus.MergeAvailable
? BuildRemoteSemanticsLogDetail( classification.Analysis )
: $"Mismatch - {classification.Summary}";
entry.Ok = classification.Status == SyncStatus.MergeAvailable;
SetItemState( cid, remoteDiffers: true, status: classification.Status,
diffSummary: classification.Summary,
localJson: localPretty, remoteJson: remotePretty );
}
}
_syncLog[logIdx] = entry;
}
}
}
// Process workflow results
if ( remoteWfs.HasValue )
{
var remoteWfSourceTextById = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
var remoteWorkflowData = remoteWfs.Value;
if ( remoteWorkflowData.TryGetProperty( "data", out var remoteWorkflowArray ) ) remoteWorkflowData = remoteWorkflowArray;
if ( remoteWorkflowData.ValueKind == JsonValueKind.Array )
{
foreach ( var remoteWorkflow in remoteWorkflowData.EnumerateArray() )
{
var remoteLocalForSource = SyncToolTransforms.ServerWorkflowToLocal( remoteWorkflow );
var remoteId = remoteLocalForSource.TryGetValue( "id", out var value ) ? value?.ToString() : null;
if ( !string.IsNullOrEmpty( remoteId ) && SyncToolTransforms.TryGetSourceText( remoteWorkflow, out var remoteSourceText ) )
remoteWfSourceTextById[remoteId] = NormalizeSourceTextForVerification( remoteSourceText );
}
}
var workflows = SyncToolTransforms.ServerToWorkflows( remoteWfs.Value );
foreach ( var (wfId, remoteLocal) in workflows )
{
var remoteNorm = NormalizeJson( JsonSerializer.Serialize( remoteLocal ) );
var logIdx = _syncLog.FindIndex( e => e.Name == $"{wfId}.workflow.yml" && e.Type == "Workflow" );
if ( logIdx < 0 ) continue;
var entry = _syncLog[logIdx];
if ( !entry.Ok ) continue;
var sourceTextMatches = localWfSourceTextById.TryGetValue( wfId, out var localSourceText )
&& remoteWfSourceTextById.TryGetValue( wfId, out var remoteSourceText )
&& localSourceText == remoteSourceText;
if ( sourceTextMatches || (localWfById.TryGetValue( wfId, out var localNorm ) && localNorm == remoteNorm) )
{
entry.Detail = sourceTextMatches ? "Verified source ✓" : "Verified ✓";
}
else
{
var wid = $"wf_{wfId}";
var localPretty = PrettyJson( localWfById.GetValueOrDefault( wfId, "{}" ) );
var remotePretty = PrettyJson( JsonSerializer.Serialize( remoteLocal ) );
var classification = ClassifyRemoteDifference( wid, localPretty, remotePretty );
entry.Detail = classification.Status == SyncStatus.MergeAvailable
? BuildRemoteSemanticsLogDetail( classification.Analysis )
: $"Mismatch - {classification.Summary}";
entry.Ok = classification.Status == SyncStatus.MergeAvailable;
SetItemState( wid, remoteDiffers: true, status: classification.Status,
diffSummary: classification.Summary,
localJson: localPretty, remoteJson: remotePretty );
}
_syncLog[logIdx] = entry;
}
}
await RetryEndpointVerificationMismatches( localEpBySlug, localEpSourceTextBySlug, publishTarget );
await RetryCollectionVerificationMismatches( localColByName, localColSourceTextByName, publishTarget );
Update();
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Post-push verification failed: {ex.Message}" );
_status = $"Push done, verification failed: {ex.Message}";
}
}
private async Task RetryEndpointVerificationMismatches( Dictionary<string, string> localEpBySlug, Dictionary<string, string> localEpSourceTextBySlug, string publishTarget )
{
var endpointMismatches = _syncLog
.Where( e => e.Type == "Endpoint" && e.Detail != null && e.Detail.Contains( "Mismatch" ) )
.Select( e => e.Name.EndsWith( ".endpoint.yml", StringComparison.OrdinalIgnoreCase )
? e.Name[..^".endpoint.yml".Length]
: e.Name )
.Where( slug => !string.IsNullOrWhiteSpace( slug ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
if ( endpointMismatches.Count == 0 )
return;
await RePushEndpointVerificationMismatches( endpointMismatches, publishTarget );
var delaysMs = new[] { 1000, 2000, 3000, 5000, 8000 };
foreach ( var delayMs in delaysMs )
{
_status = $"Waiting for remote readback ({endpointMismatches.Count} endpoint mismatch(es))...";
Update();
await Task.Delay( delayMs );
var remoteEps = await SyncToolApi.GetEndpointsForPublishTarget( publishTarget );
if ( !remoteEps.HasValue )
continue;
var data = remoteEps.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind != JsonValueKind.Array )
continue;
var remaining = new List<string>();
foreach ( var slug in endpointMismatches )
{
var remoteEndpoint = data.EnumerateArray().FirstOrDefault( ep => string.Equals( GetRemoteEndpointSlug( ep ), slug, StringComparison.OrdinalIgnoreCase ) );
if ( remoteEndpoint.ValueKind != JsonValueKind.Object )
{
remaining.Add( slug );
continue;
}
if ( SyncToolVerification.MatchEndpoint( slug, remoteEndpoint, localEpBySlug, localEpSourceTextBySlug ) != SyncToolVerification.Match.None )
{
var logIdx = _syncLog.FindIndex( e => e.Name == $"{slug}.endpoint.yml" && e.Type == "Endpoint" );
if ( logIdx >= 0 )
{
var entry = _syncLog[logIdx];
entry.Ok = true;
entry.Detail = "Verified source ✓ (after readback retry)";
_syncLog[logIdx] = entry;
}
SetItemState( $"ep_{slug}", remoteDiffers: false, diffSummary: "", status: SyncStatus.InSync );
}
else
{
remaining.Add( slug );
}
}
endpointMismatches = remaining;
if ( endpointMismatches.Count == 0 )
return;
}
}
private async Task RePushEndpointVerificationMismatches( List<string> slugs, string publishTarget )
{
if ( slugs == null || slugs.Count == 0 )
return;
_status = $"Refreshing staged endpoint override(s) ({slugs.Count})...";
Update();
foreach ( var slug in slugs )
{
try
{
var localFile = _endpointFiles.FirstOrDefault( f => string.Equals( ResourceIdFromFile( f, "endpoint" ), slug, StringComparison.OrdinalIgnoreCase ) );
if ( localFile == null || !SyncToolConfig.TryLoadSourcePayloadResource( "endpoint", localFile, out var localEp, includeDeprecated: true ) )
continue;
var localDict = JsonSerializer.Deserialize<Dictionary<string, object>>( localEp.GetRawText() );
var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { endpoint = localDict } ) );
var patchResp = await SyncToolApi.PatchEndpoint( payload, publishTarget );
if ( patchResp.HasValue )
continue;
var singleArray = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new[] { localDict } ) );
await SyncToolApi.PushEndpoints( singleArray, publishTarget );
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Verification re-push for {slug} failed: {ex.Message}" );
}
}
}
private void ShowVerificationMismatchWindow( int mismatchCount )
{
if ( !IsWindowOpen || mismatchCount <= 0 )
return;
var mismatchEntries = _syncLog
.Where( e => e.Detail != null && e.Detail.Contains( "Mismatch" ) )
.Select( e => new Dictionary<string, object>
{
["type"] = e.Type,
["name"] = e.Name,
["detail"] = e.Detail,
} )
.ToList();
var payload = new Dictionary<string, object>
{
["error"] = "SYNC_VERIFICATION_MISMATCH",
["message"] = mismatchEntries.Count == 1
? "1 pushed resource mismatches remote after verification."
: $"{mismatchEntries.Count} pushed resources mismatch remote after verification.",
["publishTarget"] = _publishTarget,
["mismatches"] = mismatchEntries,
};
var payloadElement = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payload ) );
EndpointErrorWindow.Show( "sync", "SYNC_VERIFICATION_MISMATCH", payloadElement.TryGetProperty( "message", out var message ) ? message.GetString() : "Sync verification mismatch", payloadElement );
}
private static bool IsBatchSyncEndpointUnavailable( string errorCode, string errorMessage )
{
if ( string.IsNullOrWhiteSpace( errorCode ) && string.IsNullOrWhiteSpace( errorMessage ) )
return false;
var normalizedCode = (errorCode ?? "").ToUpperInvariant();
if ( normalizedCode == "NOT_FOUND" || normalizedCode == "ENDPOINT_NOT_FOUND" || normalizedCode == "METHOD_NOT_ALLOWED"
|| normalizedCode == "HTTP_404" || normalizedCode == "HTTP_405"
|| normalizedCode.Contains( "404" ) || normalizedCode.Contains( "405" ) )
return true;
var lowerMessage = (errorMessage ?? "").ToLowerInvariant();
return lowerMessage.Contains( "404" )
|| lowerMessage.Contains( "405" )
|| lowerMessage.Contains( "not found" )
|| lowerMessage.Contains( "does not exist" )
|| lowerMessage.Contains( "not available" )
|| lowerMessage.Contains( "method not allowed" )
|| lowerMessage.Contains( "sync endpoint" );
}
private static void ShowSyncBatchFailureWindow( string errorMessage, string errorCode = null )
{
if ( !SyncToolWindow.IsWindowOpen )
return;
var payload = new Dictionary<string, object>
{
["error"] = string.IsNullOrWhiteSpace( errorCode ) ? "SYNC_BATCH_FAILED" : errorCode,
["message"] = string.IsNullOrWhiteSpace( errorMessage ) ? "Batch sync failed" : errorMessage,
["source"] = "sync",
["action"] = "PushAll"
};
var payloadElement = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( payload ) );
EndpointErrorWindow.Show( "sync", string.IsNullOrWhiteSpace( errorCode ) ? "SYNC_BATCH_FAILED" : errorCode,
payloadElement.TryGetProperty( "message", out var message ) ? message.GetString() : "Batch sync failed",
payloadElement );
}
private void PushItem( string id )
{
if ( _busy || !SyncToolConfig.IsValid ) return;
_items.TryGetValue( id, out var state );
var label = id.StartsWith( "ep_" ) ? $"endpoint '{id[3..]}'"
: id.StartsWith( "col_" ) ? $"collection '{id[4..]}'"
: id.StartsWith( "wf_" ) ? $"workflow '{id[3..]}'"
: id.StartsWith( "test_" ) ? $"test '{id[5..]}'"
: "resource";
if ( state.RemoteDiffers )
{
ConfirmDialog.Show(
"Overwrite Remote?",
$"The stored {label} is newer on your project dashboard. Pushing will overwrite the remote version with your local file.",
() => _ = DoPushItem( id ),
detail: state.DiffSummary
);
}
else
{
_ = DoPushItem( id );
}
}
private async Task DoPushItem( string id )
{
_busy = true;
_busyItem = $"push_{id}";
_status = $"Pushing {id}...";
Update();
try
{
var preflightPayload = BuildSinglePushPayload( id );
var endpointIds = id.StartsWith( "ep_" ) ? new[] { id[3..] } : null;
if ( !await RunPreflightOrOfferFix( preflightPayload, () => _ = DoPushItem( id ), endpointIds ) )
return;
bool ok;
string itemName;
string itemType;
if ( id.StartsWith( "ep_" ) )
{
var slug = id[3..];
ok = await DoPushSingleEndpointMerged( slug );
if ( ok )
await SyncToolConfig.SyncEndpointTypingsAsync();
itemName = $"{slug}.endpoint.yml";
itemType = "Endpoint";
}
else if ( id.StartsWith( "col_" ) )
{
var colName = id[4..];
ok = await DoPushSingleCollection( colName );
itemName = $"{colName}.collection.yml";
itemType = "Collection";
}
else if ( id.StartsWith( "wf_" ) )
{
var wfId = id[3..];
ok = await DoPushSingleWorkflow( wfId );
itemName = $"{wfId}.workflow.yml";
itemType = "Workflow";
}
else
{
ok = false;
itemName = id;
itemType = "Unknown";
}
// Update this item's state
SetItemState( id, result: ok ? "OK" : "FAIL", remoteDiffers: false, diffSummary: "",
status: ok ? SyncStatus.InSync : null );
// Add to sync log
_syncLog.Add( new SyncLogEntry { Name = itemName, Type = itemType, Ok = ok, Detail = ok ? ( _publishTarget == "next" ? "Pushed (next release)" : "Pushed (production)" ) : GetPushFailDetailForItem( id ) } );
// Regenerate typed C# files so Code/Data/NetworkStorage/ stays in sync
if ( ok )
{
try
{
var filesWritten = CodeGenerator.Generate();
_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = true, Detail = $"{filesWritten} files written to Code/Data/NetworkStorage/" } );
}
catch ( Exception ex )
{
_syncLog.Add( new SyncLogEntry { Name = "Code Generation", Type = "CodeGen", Ok = false, Detail = ex.Message } );
}
}
// Invalidate cached remote data so next Check for Updates is fresh,
// but preserve other items' diff state so they don't disappear
_remoteEndpoints = null;
_remoteCollections = null;
_remoteWorkflows = null;
_status = ok ? $"Pushed {id}" : $"Push failed for {id}";
ScrollToBottom();
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Push {id} failed: {ex.Message}" );
SetItemState( id, result: "FAIL" );
_status = $"Push failed for {id}: {ex.Message}";
}
finally
{
_busy = false;
_busyItem = null;
_ = SyncLocalPackageInfoAsync();
Update();
}
}
// ──────────────────────────────────────────────────────
// Pull (per-item)
// ──────────────────────────────────────────────────────
private void PullItem( string id )
{
if ( _busy || !SyncToolConfig.IsValid ) return;
var label = id.StartsWith( "ep_" ) ? $"endpoint '{id[3..]}'"
: id.StartsWith( "col_" ) ? $"collection '{id[4..]}'"
: id.StartsWith( "wf_" ) ? $"workflow '{id[3..]}'"
: id.StartsWith( "test_" ) ? $"test '{id[5..]}'"
: "resource";
_items.TryGetValue( id, out var pullState );
var warning = IsLocalChangedSinceCached( id, pullState )
? "Local changes detected. Applying remote will create a backup, then overwrite your local YAML."
: "A backup will be created before overwriting local YAML.";
new PullPreviewWindow(
DescribeSyncItem( id ),
pullState.LocalYaml ?? "",
pullState.RemoteYaml ?? "",
warning,
() => _ = DoPullItem( id, pullState.RemoteJson ) ).Show();
}
private async Task DoPullItem( string id, string approvedRemoteJson = null )
{
_busy = true;
_busyItem = $"pull_{id}";
_status = $"Pulling {id}...";
Update();
try
{
BackupLocalFileForPull( id );
var ok = TryWriteApprovedRemoteToLocal( id, approvedRemoteJson );
if ( !ok )
{
if ( id.StartsWith( "ep_" ) )
{
var slug = id[3..];
ok = await DoPullSingleEndpoint( slug );
if ( ok )
await SyncToolConfig.SyncEndpointTypingsAsync();
}
else if ( id.StartsWith( "col_" ) )
{
var colName = id[4..];
ok = await DoPullSingleCollection( colName );
}
else if ( id.StartsWith( "wf_" ) )
{
var wfId = id[3..];
ok = await DoPullSingleWorkflow( wfId );
}
}
if ( ok )
{
SetItemState( id, result: "OK", remoteDiffers: false, diffSummary: "",
status: SyncStatus.InSync );
RefreshFileList();
// Regenerate typed C# files so Code/Data/NetworkStorage/ reflects the pulled data
try
{
CodeGenerator.Generate();
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Code generation after pull failed: {ex.Message}" );
}
// Invalidate cached remote data so next Check is fresh,
// but keep _hasCheckedRemote and other items' state so they don't disappear
_remoteEndpoints = null;
_remoteCollections = null;
_remoteWorkflows = null;
}
else
{
SetItemState( id, result: "FAIL" );
}
_status = ok ? $"Pulled {id}" : $"Pull failed for {id}";
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Pull {id} failed: {ex.Message}" );
SetItemState( id, result: "FAIL" );
_status = $"Pull failed for {id}: {ex.Message}";
}
finally
{
_busy = false;
_busyItem = null;
_ = SyncLocalPackageInfoAsync();
Update();
}
}
private bool TryWriteApprovedRemoteToLocal( string id, string approvedRemoteJson )
{
if ( string.IsNullOrWhiteSpace( approvedRemoteJson ) )
return false;
try
{
var data = JsonSerializer.Deserialize<Dictionary<string, object>>( approvedRemoteJson, _readOptions );
if ( data == null )
return false;
if ( id.StartsWith( "ep_", StringComparison.OrdinalIgnoreCase ) )
{
SyncToolPullWriter.WriteSource( "endpoint", id[3..], data );
return true;
}
if ( id.StartsWith( "col_", StringComparison.OrdinalIgnoreCase ) )
{
SyncToolPullWriter.WriteSource( "collection", id[4..], data );
return true;
}
if ( id.StartsWith( "wf_", StringComparison.OrdinalIgnoreCase ) )
{
SyncToolPullWriter.WriteSource( "workflow", id[3..], data );
return true;
}
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Failed to write approved pull preview for {id}: {ex.Message}" );
}
return false;
}
/// <summary>
/// Get a human-readable failure message after a failed push, using SyncToolApi error state.
/// </summary>
private static string GetPushFailDetail( string resource, string resourceId = null )
{
if ( SyncToolApi.LastErrorCode == "KEY_UPGRADE_REQUIRED" )
return "Key uses old format — regenerate at sbox.cool";
if ( SyncToolApi.LastErrorCode == "FORBIDDEN" )
return $"No write permission for {resource}";
if ( !string.IsNullOrWhiteSpace( resourceId ) )
{
var resourceMessage = SyncToolApi.GetLastResourceErrorMessage( resource, resourceId );
if ( !string.IsNullOrEmpty( resourceMessage ) )
return resourceMessage;
}
var pathMessage = SyncToolApi.GetLastErrorMessage( resource );
if ( !string.IsNullOrEmpty( pathMessage ) )
return pathMessage;
if ( !string.IsNullOrEmpty( SyncToolApi.LastErrorMessage ) )
return SyncToolApi.LastErrorMessage;
return "Push failed";
}
private static string GetPushFailDetailForItem( string id )
{
if ( id.StartsWith( "ep_" ) )
return GetPushFailDetail( "endpoints", id[3..] );
if ( id.StartsWith( "col_" ) )
return GetPushFailDetail( "collections", id[4..] );
if ( id.StartsWith( "wf_" ) )
return GetPushFailDetail( "workflows", id[3..] );
return GetPushFailDetail( "resource" );
}
// ──────────────────────────────────────────────────────
// Push implementation
// ──────────────────────────────────────────────────────
/// <summary>
/// Refuses a push while an endpoint's script file is missing, ambiguous, or unreadable. The problem is
/// shown in the Sync window and nothing is sent.
/// </summary>
private bool EndpointScriptsReadyForPush()
{
var problems = SyncToolConfig.EndpointScriptProblems();
if ( problems.Count == 0 )
return true;
var message = "Endpoint script files need fixing before push:\n" + string.Join( "\n", problems.Values );
SyncToolApi.ReportLocalError( "endpoints", message );
ShowSyncBatchFailureWindow( message, "LOCAL_SCRIPT_FILE" );
ReportPushBlocked( problems );
SetStatus( "Push blocked: endpoint script file problem", true );
return false;
}
private async Task<bool> DoPushAllEndpoints()
{
try
{
var localEps = SyncToolConfig.LoadSourcePayloadResources( "endpoint", includeDeprecated: false );
if ( localEps.Count == 0 )
{
SyncToolApi.ReportLocalError( "endpoints", "No readable local endpoint source files were loaded for push." );
return false;
}
var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( localEps ) );
var resp = await SyncToolApi.PushEndpoints( serverFmt, _publishTarget );
if ( !resp.HasValue )
return false;
await SyncToolConfig.SyncEndpointTypingsAsync();
return true;
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint push preparation failed: {ex.Message}", ex );
return false;
}
}
/// <summary>
/// Push all resources (endpoints, collections, workflows) in a single batch request.
/// Returns a tuple of (endpointsOk, collectionsOk, workflowsOk).
/// Falls back to individual pushes if the batch endpoint is not available (HTTP 404).
/// </summary>
private async Task<(bool epOk, bool colOk, bool wfOk)> DoPushAllBatched()
{
try
{
// Load raw local source. Backend compiler owns canonicalization.
var localEps = SyncToolConfig.LoadSourcePayloadResources( "endpoint", includeDeprecated: false );
var localCols = SyncToolConfig.LoadSourcePayloadResources( "collection" );
var localWfs = SyncToolConfig.LoadSourcePayloadResources( "workflow" );
var hasEndpoints = localEps.Count > 0;
var hasCollections = localCols.Count > 0;
var hasWorkflows = localWfs.Count > 0;
if ( !hasEndpoints && !hasCollections && !hasWorkflows )
return (false, false, false);
// For next/staged pushes, keep endpoints and collections in one /sync request.
// Separate PATCH calls can land on different API workers and overwrite each
// other's revision override buckets before caches converge.
var batchPayload = new Dictionary<string, object>();
if ( hasEndpoints )
batchPayload["endpoints"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localEps ) );
if ( hasCollections )
batchPayload["collections"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localCols ) );
if ( hasWorkflows )
batchPayload["workflows"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localWfs ) );
var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( batchPayload ) );
var resp = await SyncToolApi.PushSync( payload, _publishTarget );
// Check if batch endpoint is not available (404/405) — fall back to individual pushes
var errCode = SyncToolApi.LastErrorCode ?? "";
var errMsg = SyncToolApi.LastErrorMessage ?? "";
if ( !resp.HasValue )
{
var isBatchSyncUnavailable = IsBatchSyncEndpointUnavailable( errCode, errMsg );
if ( isBatchSyncUnavailable )
{
// Batch sync endpoint not available, falling back to individual pushes
return await DoPushAllIndividual( hasEndpoints, hasCollections, hasWorkflows );
}
// A refused batch names the endpoints with broken scripts. Those rows fail; the rest read "not pushed (blocked)".
if ( SyncToolApi.LastErrorBody is JsonElement refusalBody && BlockPushForEndpointScripts( refusalBody, null ) )
return (false, false, false);
var finalErrMsg = string.IsNullOrEmpty( errMsg ) ? "Unknown error" : errMsg;
SyncToolApi.ReportLocalError( "sync", $"Batch sync failed: {finalErrMsg}" );
ShowSyncBatchFailureWindow( finalErrMsg, errCode );
return (false, false, false);
}
// Parse per-resource results from batch response
var epOk = !hasEndpoints || (resp.Value.TryGetProperty( "endpoints", out var epResult ) &&
epResult.TryGetProperty( "ok", out var epOkProp ) && epOkProp.GetBoolean());
var colOk = !hasCollections || (resp.Value.TryGetProperty( "collections", out var colResult ) &&
colResult.TryGetProperty( "ok", out var colOkProp ) && colOkProp.GetBoolean());
var wfOk = !hasWorkflows || (resp.Value.TryGetProperty( "workflows", out var wfResult ) &&
wfResult.TryGetProperty( "ok", out var wfOkProp ) && wfOkProp.GetBoolean());
if ( hasEndpoints && epOk )
await SyncToolConfig.SyncEndpointTypingsAsync();
return (hasEndpoints && epOk, hasCollections && colOk, hasWorkflows && wfOk);
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "sync", $"Batch sync preparation failed: {ex.Message}", ex );
return (false, false, false);
}
}
/// <summary>
/// Next-release push path: use the dedicated endpoints/collections routes for staged
/// overrides so immediate verification reads the same target that was written.
/// </summary>
private async Task<(bool epOk, bool colOk, bool wfOk)> DoPushNextWithDedicatedEndpoints( bool hasEndpoints, bool hasCollections, bool hasWorkflows )
{
var endpointsTask = hasEndpoints ? DoPushAllEndpoints() : Task.FromResult( false );
var collectionsTask = hasCollections ? DoPushCollections() : Task.FromResult( false );
var workflowsTask = hasWorkflows ? DoPushAllWorkflows() : Task.FromResult( false );
await Task.WhenAll( endpointsTask, collectionsTask, workflowsTask );
return (await endpointsTask, await collectionsTask, await workflowsTask);
}
private async Task<(bool epOk, bool wfOk)> DoPushEndpointsAndWorkflowsBatched( List<JsonElement> localEps, List<JsonElement> localWfs, bool hasEndpoints, bool hasWorkflows )
{
try
{
var batchPayload = new Dictionary<string, object>();
if ( hasEndpoints )
batchPayload["endpoints"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localEps ) );
if ( hasWorkflows )
batchPayload["workflows"] = JsonSerializer.Deserialize<object>( JsonSerializer.Serialize( localWfs ) );
var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( batchPayload ) );
var resp = await SyncToolApi.PushSync( payload, _publishTarget );
var errCode = SyncToolApi.LastErrorCode ?? "";
var errMsg = SyncToolApi.LastErrorMessage ?? "";
if ( !resp.HasValue )
{
if ( SyncToolApi.LastErrorBody is JsonElement refusalBody && BlockPushForEndpointScripts( refusalBody, null ) )
return (false, false);
var finalErrMsg = string.IsNullOrEmpty( errMsg ) ? "Unknown error" : errMsg;
SyncToolApi.ReportLocalError( "sync", $"Endpoint/workflow batch sync failed: {finalErrMsg}" );
ShowSyncBatchFailureWindow( finalErrMsg, errCode );
return (false, false);
}
var epOk = !hasEndpoints || (resp.Value.TryGetProperty( "endpoints", out var epResult ) &&
epResult.TryGetProperty( "ok", out var epOkProp ) && epOkProp.GetBoolean());
var wfOk = !hasWorkflows || (resp.Value.TryGetProperty( "workflows", out var wfResult ) &&
wfResult.TryGetProperty( "ok", out var wfOkProp ) && wfOkProp.GetBoolean());
return (hasEndpoints && epOk, hasWorkflows && wfOk);
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "sync", $"Endpoint/workflow batch sync preparation failed: {ex.Message}", ex );
return (false, false);
}
}
/// <summary>
/// Fallback: push endpoints, collections, workflows in parallel individual requests.
/// </summary>
private async Task<(bool epOk, bool colOk, bool wfOk)> DoPushAllIndividual( bool hasEndpoints, bool hasCollections, bool hasWorkflows )
{
var pushEpTask = hasEndpoints ? DoPushAllEndpoints() : Task.FromResult( false );
var pushColTask = hasCollections ? DoPushCollections() : Task.FromResult( false );
var pushWfTask = hasWorkflows ? DoPushAllWorkflows() : Task.FromResult( false );
await Task.WhenAll( pushEpTask, pushColTask, pushWfTask );
return (await pushEpTask, await pushColTask, await pushWfTask);
}
/// <summary>
/// Push a single endpoint by merging it into the remote list.
/// GETs remote endpoints, replaces the matching slug, PUTs the merged list.
/// </summary>
private async Task<bool> DoPushSingleEndpointMerged( string slug )
{
try
{
var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
if ( localFile == null )
{
SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint file for {slug} was not found." );
return false;
}
if ( !SyncToolConfig.TryLoadSourcePayloadResource( "endpoint", localFile, out var localEp, includeDeprecated: true ) )
{
SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint file for {slug} could not be read." );
return false;
}
var scriptError = SyncToolConfig.EndpointScriptError( localFile );
if ( scriptError != null )
{
SyncToolApi.ReportLocalError( "endpoints", $"Local endpoint file for {slug} has a script problem: {scriptError}" );
return false;
}
// Push raw source only; the backend compiler owns canonicalization.
var localDict = JsonSerializer.Deserialize<Dictionary<string, object>>( localEp.GetRawText() );
var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { endpoint = localDict } ) );
// Try PATCH first (server handles upsert)
var resp = await SyncToolApi.PatchEndpoint( payload, _publishTarget );
if ( resp.HasValue )
return true;
// Fall back to GET-merge-PUT if PATCH not supported (404/405)
var errCode = SyncToolApi.LastErrorCode ?? "";
var errMsg = SyncToolApi.LastErrorMessage ?? "";
var isNotSupported = errCode.Contains( "404" ) || errCode.Contains( "405" ) || errCode == "NOT_FOUND" || errMsg.Contains( "404" ) || errMsg.Contains( "405" );
if ( !isNotSupported )
{
// Real error, not "endpoint not found"
return false;
}
var remoteResp = await SyncToolApi.GetEndpointsForPublishTarget( _publishTarget );
if ( !remoteResp.HasValue ) return false;
var data = remoteResp.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind != JsonValueKind.Array )
{
SyncToolApi.ReportLocalError( "endpoints", "Remote endpoints payload was not an array." );
return false;
}
var merged = new List<object>();
var replaced = false;
foreach ( var ep in data.EnumerateArray() )
{
var epSlug = ep.TryGetProperty( "slug", out var s ) ? s.GetString() : "";
if ( epSlug == slug )
{
if ( ep.TryGetProperty( "id", out var idEl ) )
localDict["id"] = idEl.GetString();
if ( ep.TryGetProperty( "createdAt", out var caEl ) )
localDict["createdAt"] = caEl.GetString();
merged.Add( localDict );
replaced = true;
}
else
{
merged.Add( ep );
}
}
if ( !replaced )
{
localDict["id"] = Guid.NewGuid().ToString( "N" )[..16];
merged.Add( localDict );
}
var mergedJson = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( merged ) );
resp = await SyncToolApi.PushEndpoints( mergedJson, _publishTarget );
return resp.HasValue;
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "endpoints", $"Single endpoint push preparation failed for {slug}: {ex.Message}", ex );
return false;
}
}
private async Task<bool> DoPushSingleCollection( string colName )
{
try
{
var localFile = _collectionFiles.FirstOrDefault( f => ResourceIdFromFile( f, "collection" ) == colName );
if ( localFile == null || !SyncToolConfig.TryLoadSourcePayloadResource( "collection", localFile, out var localCollection ) )
{
SyncToolApi.ReportLocalError( "collections", $"Local collection file for {colName} was not found." );
return false;
}
// Try PATCH first (server handles upsert and source compilation)
var collectionPayload = JsonSerializer.Deserialize<Dictionary<string, object>>( localCollection.GetRawText() );
var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { collection = collectionPayload } ) );
var resp = await SyncToolApi.PatchCollection( payload, _publishTarget );
if ( resp.HasValue )
return true;
// Fall back to PUT if PATCH not supported
var errCode = SyncToolApi.LastErrorCode ?? "";
var errMsg = SyncToolApi.LastErrorMessage ?? "";
var isNotSupported = errCode.Contains( "404" ) || errCode.Contains( "405" ) || errCode == "NOT_FOUND" || errMsg.Contains( "404" ) || errMsg.Contains( "405" );
if ( !isNotSupported )
return false;
var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new List<JsonElement> { localCollection } ) );
resp = await SyncToolApi.PushCollections( serverFmt, _publishTarget );
return resp.HasValue;
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "collections", $"Single collection push failed for {colName}: {ex.Message}", ex );
return false;
}
}
private async Task<bool> DoPushSingleWorkflow( string wfId )
{
try
{
var localFile = _workflowFiles.FirstOrDefault( f => ResourceIdFromFile( f, "workflow" ) == wfId );
if ( localFile == null || !SyncToolConfig.TryLoadSourcePayloadResource( "workflow", localFile, out var localWf ) )
{
SyncToolApi.ReportLocalError( "workflows", $"Local workflow file for {wfId} was not found." );
return false;
}
// Try PATCH first (server handles upsert and source compilation)
var workflowPayload = JsonSerializer.Deserialize<Dictionary<string, object>>( localWf.GetRawText() );
var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { workflow = workflowPayload } ) );
var resp = await SyncToolApi.PatchWorkflow( payload );
if ( resp.HasValue )
return true;
// Fall back to GET-merge-PUT if PATCH not supported
var errCode = SyncToolApi.LastErrorCode ?? "";
var errMsg = SyncToolApi.LastErrorMessage ?? "";
var isNotSupported = errCode.Contains( "404" ) || errCode.Contains( "405" ) || errCode == "NOT_FOUND" || errMsg.Contains( "404" ) || errMsg.Contains( "405" );
if ( !isNotSupported )
return false;
var existing = await SyncToolApi.GetWorkflows();
var serverFmt = SyncToolTransforms.WorkflowsToServer( new List<JsonElement> { localWf }, existing );
resp = await SyncToolApi.PushWorkflows( serverFmt );
return resp.HasValue;
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "workflows", $"Single workflow push failed for {wfId}: {ex.Message}", ex );
return false;
}
}
private async Task<bool> DoPushCollections()
{
try
{
var collections = SyncToolConfig.LoadSourcePayloadResources( "collection" );
if ( collections.Count == 0 )
{
SyncToolApi.ReportLocalError( "collections", "No readable local collection source files were loaded for push." );
return false;
}
// Staged/Main collection writes must use the per-resource PATCH route. The bulk
// PUT route can return success while updating only the live collection set on
// older backends, which makes immediate verification read back the old Live row.
if ( string.Equals( _publishTarget, "next", StringComparison.OrdinalIgnoreCase ) )
{
var allOk = true;
foreach ( var collection in collections )
{
var collectionPayload = JsonSerializer.Deserialize<Dictionary<string, object>>( collection.GetRawText() );
var payload = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( new { collection = collectionPayload } ) );
var resp = await SyncToolApi.PatchCollection( payload, _publishTarget );
allOk &= resp.HasValue;
}
return allOk;
}
var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( collections ) );
var bulkResp = await SyncToolApi.PushCollections( serverFmt, _publishTarget );
return bulkResp.HasValue;
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "collections", $"Local collection push preparation failed: {ex.Message}", ex );
return false;
}
}
private async Task<bool> DoPushAllWorkflows()
{
try
{
var localWfs = SyncToolConfig.LoadSourcePayloadResources( "workflow" );
if ( localWfs.Count == 0 )
{
SyncToolApi.ReportLocalError( "workflows", "No readable local workflow source files were loaded for push." );
return false;
}
var serverFmt = JsonSerializer.Deserialize<JsonElement>( JsonSerializer.Serialize( localWfs ) );
var resp = await SyncToolApi.PushWorkflows( serverFmt );
return resp.HasValue;
}
catch ( Exception ex )
{
SyncToolApi.ReportLocalError( "workflows", $"Local workflow push preparation failed: {ex.Message}", ex );
return false;
}
}
// ──────────────────────────────────────────────────────
// Pull implementation
// ──────────────────────────────────────────────────────
private async Task<bool> DoPullSingleEndpoint( string slug )
{
// Use cached selected-target remote data if available, otherwise fetch that target.
var resp = _remoteEndpoints ?? await SyncToolApi.GetEndpointsForPublishTarget( _publishTarget );
if ( !resp.HasValue ) return false;
try
{
var data = resp.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind != JsonValueKind.Array ) return false;
foreach ( var ep in data.EnumerateArray() )
{
var epSlug = GetRemoteEndpointSlug( ep );
if ( !string.Equals( epSlug, slug, StringComparison.OrdinalIgnoreCase ) ) continue;
if ( SyncToolConfig.IsEndpointDeprecated( ep ) ) return false;
return SyncToolPullWriter.SaveEndpoint( slug, ep );
}
return false;
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Pull endpoint {slug} failed: {ex.Message}" );
return false;
}
}
private async Task<bool> DoPullCollections()
{
var resp = _remoteCollections ?? await SyncToolApi.GetCollectionsForPublishTarget( _publishTarget );
if ( !resp.HasValue ) return false;
try
{
return SyncToolPullWriter.SaveCollections( resp.Value ) > 0;
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Pull collections failed: {ex.Message}" );
return false;
}
}
private async Task<bool> DoPullSingleCollection( string colName )
{
var resp = _remoteCollections ?? await SyncToolApi.GetCollectionsForPublishTarget( _publishTarget );
if ( !resp.HasValue ) return false;
try
{
var data = resp.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind != JsonValueKind.Array ) return false;
foreach ( var collection in data.EnumerateArray() )
{
var local = SyncToolTransforms.ServerCollectionToLocal( collection );
var name = local.TryGetValue( "name", out var value ) ? value?.ToString() : null;
if ( string.Equals( name, colName, StringComparison.OrdinalIgnoreCase ) )
return SyncToolPullWriter.SaveCollection( colName, collection );
}
return false;
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Pull collection {colName} failed: {ex.Message}" );
return false;
}
}
private async Task<bool> DoPullSingleWorkflow( string wfId )
{
var resp = _remoteWorkflows ?? await SyncToolApi.GetWorkflows();
if ( !resp.HasValue ) return false;
try
{
var data = resp.Value;
if ( data.TryGetProperty( "data", out var d ) ) data = d;
if ( data.ValueKind != JsonValueKind.Array ) return false;
foreach ( var workflow in data.EnumerateArray() )
{
var local = SyncToolTransforms.ServerWorkflowToLocal( workflow );
var id = local.TryGetValue( "id", out var value ) ? value?.ToString() : null;
if ( string.Equals( id, wfId, StringComparison.OrdinalIgnoreCase ) )
return SyncToolPullWriter.SaveWorkflow( wfId, workflow );
}
return false;
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Pull workflow {wfId} failed: {ex.Message}" );
return false;
}
}
// ──────────────────────────────────────────────────────
// Diff breakdown helpers
// ──────────────────────────────────────────────────────
/// <summary>
/// Compare two endpoint resources key-by-key.
/// Categorizes changes as cosmetic (name, description, notes) vs structural (steps, input, response, method).
/// </summary>
private string DiffEndpoint( string localJson, string remoteJson, string slug )
{
try
{
var local = JsonSerializer.Deserialize<JsonElement>( localJson );
var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );
var cosmetic = new List<string>();
var structural = new List<string>();
// ── Cosmetic fields (harmless label changes) ──
var localName = GetStr( local, "name" );
var remoteName = GetStr( remote, "name" );
if ( localName != remoteName )
cosmetic.Add( $"name: \"{remoteName}\" → \"{localName}\"" );
var localDesc = GetStr( local, "description" );
var remoteDesc = GetStr( remote, "description" );
if ( localDesc != remoteDesc )
{
var label = string.IsNullOrEmpty( remoteDesc ) ? "description added locally" : "description differs";
cosmetic.Add( label );
}
var localNotes = GetStr( local, "notes" );
var remoteNotes = GetStr( remote, "notes" );
if ( localNotes != remoteNotes )
{
var label = string.IsNullOrEmpty( remoteNotes ) ? "notes added locally" : "notes differ";
cosmetic.Add( label );
}
var localEnabled = !local.TryGetProperty( "enabled", out var le ) || le.ValueKind != JsonValueKind.False;
var remoteEnabled = !remote.TryGetProperty( "enabled", out var re ) || re.ValueKind != JsonValueKind.False;
if ( localEnabled != remoteEnabled )
cosmetic.Add( $"enabled: {remoteEnabled} → {localEnabled}" );
// ── Structural fields (logic changes) ──
var localMethod = GetStr( local, "method" );
var remoteMethod = GetStr( remote, "method" );
if ( localMethod != remoteMethod )
structural.Add( $"method: {remoteMethod} → {localMethod}" );
var localInput = local.TryGetProperty( "input", out var li ) ? NormalizeJson( li.ToString() ) : "{}";
var remoteInput = remote.TryGetProperty( "input", out var ri ) ? NormalizeJson( ri.ToString() ) : "{}";
if ( localInput != remoteInput )
structural.Add( "input schema" );
var localSteps = local.TryGetProperty( "steps", out var ls ) ? NormalizeJson( ls.ToString() ) : "[]";
var remoteSteps = remote.TryGetProperty( "steps", out var rs ) ? NormalizeJson( rs.ToString() ) : "[]";
if ( localSteps != remoteSteps )
{
var lCount = ls.ValueKind == JsonValueKind.Array ? ls.GetArrayLength() : 0;
var rCount = rs.ValueKind == JsonValueKind.Array ? rs.GetArrayLength() : 0;
structural.Add( lCount != rCount ? $"steps: {rCount} → {lCount}" : "step logic" );
}
var localResp = local.TryGetProperty( "response", out var lresp ) ? NormalizeJson( lresp.ToString() ) : "{}";
var remoteResp = remote.TryGetProperty( "response", out var rresp ) ? NormalizeJson( rresp.ToString() ) : "{}";
if ( localResp != remoteResp )
structural.Add( "response" );
// ── Build summary ──
if ( structural.Count == 0 && cosmetic.Count > 0 )
return $"cosmetic only: {string.Join( ", ", cosmetic )}";
if ( structural.Count > 0 && cosmetic.Count == 0 )
return $"logic differs: {string.Join( ", ", structural )}";
if ( structural.Count > 0 && cosmetic.Count > 0 )
return $"logic: {string.Join( ", ", structural )} | cosmetic: {string.Join( ", ", cosmetic )}";
return "Field ordering differs (content identical)";
}
catch { return "Content differs (click View Diff)"; }
}
private static string GetStr( JsonElement el, string key )
{
return el.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
}
private string DiffCollectionSchema( string localJson, string remoteJson )
{
try
{
if ( string.IsNullOrEmpty( localJson ) )
return "New — no local file exists";
var local = JsonSerializer.Deserialize<JsonElement>( localJson );
var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );
var schemaChanges = new List<string>();
var metaChanges = new List<string>();
// Compare schema (structural)
var localSchema = local.TryGetProperty( "schema", out var ls ) ? NormalizeJson( ls.ToString() ) : "{}";
var remoteSchema = remote.TryGetProperty( "schema", out var rs ) ? NormalizeJson( rs.ToString() ) : "{}";
if ( localSchema == remoteSchema )
schemaChanges.Add( "schema: identical" );
else
schemaChanges.Add( "schema: differs" );
// Compare metadata fields (non-structural)
CompareField( local, remote, "name", metaChanges );
CompareField( local, remote, "description", metaChanges );
CompareField( local, remote, "notes", metaChanges );
CompareField( local, remote, "accessMode", metaChanges );
CompareField( local, remote, "visibility", metaChanges );
CompareField( local, remote, "collectionType", metaChanges );
CompareField( local, remote, "maxRecords", metaChanges );
CompareField( local, remote, "allowRecordDelete", metaChanges );
CompareField( local, remote, "requireSaveVersion", metaChanges );
CompareField( local, remote, "webhookOnRateLimit", metaChanges );
CompareField( local, remote, "rateLimitAction", metaChanges );
// Compare rateLimits object
var localRL = local.TryGetProperty( "rateLimits", out var lrl ) ? NormalizeJson( lrl.ToString() ) : "";
var remoteRL = remote.TryGetProperty( "rateLimits", out var rrl ) ? NormalizeJson( rrl.ToString() ) : "";
if ( localRL != remoteRL )
metaChanges.Add( "rateLimits" );
// Compare constants/tables
var localConst = local.TryGetProperty( "constants", out var lc ) ? NormalizeJson( lc.ToString() ) : "";
var remoteConst = remote.TryGetProperty( "constants", out var rc ) ? NormalizeJson( rc.ToString() ) : "";
if ( localConst != remoteConst )
metaChanges.Add( "constants" );
var localTables = local.TryGetProperty( "tables", out var lt ) ? NormalizeJson( lt.ToString() ) : "";
var remoteTables = remote.TryGetProperty( "tables", out var rt ) ? NormalizeJson( rt.ToString() ) : "";
if ( localTables != remoteTables )
metaChanges.Add( "tables" );
var parts = new List<string>();
parts.AddRange( schemaChanges );
if ( metaChanges.Count > 0 )
parts.Add( $"metadata differs: {string.Join( ", ", metaChanges )}" );
return string.Join( " | ", parts );
}
catch { return "Content differs (click View Diff)"; }
}
private static void CompareField( JsonElement local, JsonElement remote, string field, List<string> changes )
{
var lv = local.TryGetProperty( field, out var l ) ? l.ToString() : "";
var rv = remote.TryGetProperty( field, out var r ) ? r.ToString() : "";
if ( lv != rv )
changes.Add( $"{field}: {lv} → {rv}" );
}
/// <summary>
/// Merge only non-structural metadata from remote into the local collection file.
/// Pulls: description, accessMode, rateLimits, rateLimitAction, maxRecords, etc.
/// Keeps: schema, constants, tables unchanged (local version preserved).
/// </summary>
private void MergeMetadata( string id )
{
if ( _busy || !id.StartsWith( "col_" ) ) return;
_items.TryGetValue( id, out var state );
if ( string.IsNullOrEmpty( state.RemoteJson ) ) return;
var colName = id[4..];
ConfirmDialog.Show(
"Merge Metadata from Remote",
$"This will update non-structural fields (description, accessMode, rateLimits, etc.) in your local '{colName}' collection file with values from the server. Your schema, constants, and tables will NOT be changed.",
() =>
{
try
{
var localFile = _collectionFiles.FirstOrDefault( f => ResourceIdFromFile( f, "collection" ) == colName );
if ( localFile == null ) return;
if ( !TryReadLocalResourceFile( localFile, "collection", out var localElement ) )
return;
var local = JsonSerializer.Deserialize<Dictionary<string, object>>( localElement.GetRawText(), _readOptions );
var remote = JsonSerializer.Deserialize<JsonElement>( state.RemoteJson );
// Merge metadata fields from remote (non-structural)
string[] metaFields = { "description", "accessMode", "collectionType", "maxRecords",
"allowRecordDelete", "requireSaveVersion", "rateLimitAction", "webhookOnRateLimit", "rateLimits" };
foreach ( var field in metaFields )
{
if ( remote.TryGetProperty( field, out var val ) )
{
local[field] = val.ValueKind switch
{
JsonValueKind.String => (object)val.GetString(),
JsonValueKind.Number => val.TryGetInt32( out var i ) ? i : val.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => val
};
}
}
// Save — preserves local schema, constants, tables
SyncToolPullWriter.WriteSource( "collection", colName, local );
SetItemState( id, result: "OK", remoteDiffers: false, status: SyncStatus.InSync, diffSummary: "" );
_status = $"Merged metadata for {colName}";
Update();
}
catch ( Exception ex )
{
_status = $"Merge failed: {ex.Message}";
Update();
}
}
);
}
/// <summary>
/// Pull additive remote-only fields into every eligible local file.
/// </summary>
private void PullAllRemoteSemantics()
{
if ( _busy || !SyncToolConfig.IsValid ) return;
var ids = GetRemoteSemanticsIds();
if ( ids.Length == 0 ) return;
var detail = string.Join( "\n", ids.Select( DescribeSyncItem ) );
ConfirmDialog.Show(
"Pull Remote Semantics",
$"This will update {ids.Length} local file(s) with additive remote-only fields. Files with modified or missing content are excluded from this action.",
() => _ = DoPullAllRemoteSemantics( ids ),
detail: detail );
}
private Task DoPullAllRemoteSemantics( string[] ids )
{
_busy = true;
_busyItem = "pull_remote_semantics_all";
_status = $"Pulling remote semantics for {ids.Length} item(s)...";
Update();
try
{
var okCount = 0;
var failCount = 0;
foreach ( var id in ids )
{
if ( !_items.TryGetValue( id, out var state ) || string.IsNullOrEmpty( state.RemoteJson ) )
{
failCount++;
continue;
}
if ( TryApplyRemoteJsonToLocal( id, state.RemoteJson, out var error ) )
{
okCount++;
SetItemState( id, result: "OK", remoteDiffers: false, status: SyncStatus.InSync, diffSummary: "" );
}
else
{
failCount++;
SetItemState( id, result: "FAIL" );
Log.Warning( $"[SyncTool] Pull remote semantics failed for {id}: {error}" );
}
}
if ( okCount > 0 )
{
RefreshFileList();
TryRunCodeGeneration( "pull remote semantics" );
InvalidateRemoteCache();
}
_status = failCount == 0
? $"Pulled remote semantics for {okCount} item(s)"
: $"Pulled remote semantics for {okCount} item(s), {failCount} failed";
}
finally
{
_busy = false;
_busyItem = null;
_ = SyncLocalPackageInfoAsync();
Update();
}
return Task.CompletedTask;
}
/// <summary>
/// Open the MergeViewWindow showing additive remote-only fields with explanations.
/// </summary>
private void OpenMergeView( string id, string name )
{
if ( !_items.TryGetValue( id, out var state ) ) return;
var (added, changed, _) = MergeViewWindow.AnalyzeDifferences( state.LocalJson ?? "{}", state.RemoteJson ?? "{}" );
var resourceType = id.StartsWith( "ep_" ) ? "endpoint"
: id.StartsWith( "col_" ) ? "collection"
: "workflow";
var capturedId = id;
var capturedName = name;
var window = new MergeViewWindow( name, resourceType, added, changed,
onMerge: () => DoMergeItem( capturedId ),
onViewDiff: () => OpenDiffView( capturedId, capturedName ) );
window.Show();
}
/// <summary>
/// Accept the additive remote semantics by saving the remote JSON to the local file.
/// </summary>
private void DoMergeItem( string id )
{
_items.TryGetValue( id, out var state );
if ( string.IsNullOrEmpty( state.RemoteJson ) ) return;
if ( TryApplyRemoteJsonToLocal( id, state.RemoteJson, out var error ) )
{
SetItemState( id, result: "OK", remoteDiffers: false, status: SyncStatus.InSync, diffSummary: "" );
RefreshFileList();
TryRunCodeGeneration( "pull remote semantics" );
InvalidateRemoteCache();
_status = $"Pulled remote semantics for {id}";
Update();
}
else
{
_status = $"Pull remote semantics failed: {error}";
Update();
}
}
private bool TryApplyRemoteJsonToLocal( string id, string remoteJson, out string error )
{
try
{
var json = TryGetCurrentLocalJson( id, out var localJson )
? MergeRemoteOnlyFields( localJson, remoteJson )
: PrettyJson( remoteJson );
if ( id.StartsWith( "ep_" ) )
{
var slug = id[3..];
var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
SyncToolPullWriter.WriteSource( "endpoint", slug, data );
}
else if ( id.StartsWith( "col_" ) )
{
var colName = id[4..];
var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
SyncToolPullWriter.WriteSource( "collection", colName, data );
}
else if ( id.StartsWith( "wf_" ) )
{
var wfId = id[3..];
var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
SyncToolPullWriter.WriteSource( "workflow", wfId, data );
}
else if ( id.StartsWith( "test_" ) )
{
var testId = id[5..];
var data = JsonSerializer.Deserialize<Dictionary<string, object>>( json, _readOptions );
SyncToolPullWriter.WriteSource( "test", testId, data );
}
else
{
error = $"Unsupported sync item: {id}";
return false;
}
error = null;
return true;
}
catch ( Exception ex )
{
error = ex.Message;
return false;
}
}
private bool TryGetCurrentLocalJson( string id, out string localJson )
{
localJson = null;
JsonElement local;
if ( id.StartsWith( "ep_" ) )
{
var slug = id[3..];
var localFile = _endpointFiles.FirstOrDefault( f => ResourceIdFromFile( f, "endpoint" ) == slug );
if ( localFile == null || !TryReadLocalResourceFile( localFile, "endpoint", out local ) )
return false;
localJson = JsonSerializer.Serialize( SyncToolTransforms.ServerEndpointToLocal( local ), new JsonSerializerOptions { WriteIndented = true } );
return true;
}
if ( id.StartsWith( "col_" ) )
{
var colName = id[4..];
var localFile = _collectionFiles.FirstOrDefault( f => ResourceIdFromFile( f, "collection" ) == colName );
if ( localFile == null || !TryReadLocalResourceFile( localFile, "collection", out local ) )
return false;
var data = JsonSerializer.Deserialize<Dictionary<string, object>>( local.GetRawText(), _readOptions );
localJson = JsonSerializer.Serialize( SyncToolTransforms.StripServerManagedFields( data ), new JsonSerializerOptions { WriteIndented = true } );
return true;
}
if ( id.StartsWith( "wf_" ) )
{
var wfId = id[3..];
var localFile = SyncToolConfig.FindWorkflowFileById( wfId );
if ( localFile == null || !TryReadLocalResourceFile( localFile, "workflow", out local ) )
return false;
localJson = JsonSerializer.Serialize( SyncToolTransforms.ServerWorkflowToLocal( local ), new JsonSerializerOptions { WriteIndented = true } );
return true;
}
return false;
}
private static string MergeRemoteOnlyFields( string localJson, string remoteJson )
{
var local = JsonSerializer.Deserialize<JsonElement>( localJson );
var remote = JsonSerializer.Deserialize<JsonElement>( remoteJson );
var merged = MergeRemoteOnlyFields( local, remote );
return JsonSerializer.Serialize( merged, new JsonSerializerOptions { WriteIndented = true } );
}
private static object MergeRemoteOnlyFields( JsonElement local, JsonElement remote )
{
if ( local.ValueKind == JsonValueKind.Object && remote.ValueKind == JsonValueKind.Object )
{
var result = new Dictionary<string, object>( StringComparer.OrdinalIgnoreCase );
var remoteProps = remote.EnumerateObject().ToDictionary( prop => prop.Name, prop => prop.Value, StringComparer.OrdinalIgnoreCase );
foreach ( var localProp in local.EnumerateObject() )
{
result[localProp.Name] = remoteProps.TryGetValue( localProp.Name, out var remoteValue )
? MergeRemoteOnlyFields( localProp.Value, remoteValue )
: JsonElementToPlainObject( localProp.Value );
}
foreach ( var remoteProp in remote.EnumerateObject() )
{
if ( !result.ContainsKey( remoteProp.Name ) )
result[remoteProp.Name] = JsonElementToPlainObject( remoteProp.Value );
}
return result;
}
return JsonElementToPlainObject( local );
}
private static object JsonElementToPlainObject( JsonElement value )
{
return value.ValueKind switch
{
JsonValueKind.Object => value.EnumerateObject()
.ToDictionary( prop => prop.Name, prop => JsonElementToPlainObject( prop.Value ), StringComparer.OrdinalIgnoreCase ),
JsonValueKind.Array => value.EnumerateArray().Select( JsonElementToPlainObject ).ToList(),
JsonValueKind.String => value.GetString(),
JsonValueKind.Number when value.TryGetInt64( out var integer ) => integer,
JsonValueKind.Number when value.TryGetDouble( out var number ) => number,
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null
};
}
private void TryRunCodeGeneration( string context )
{
try
{
CodeGenerator.Generate();
}
catch ( Exception ex )
{
Log.Warning( $"[SyncTool] Code generation after {context} failed: {ex.Message}" );
}
}
private void InvalidateRemoteCache()
{
_remoteEndpoints = null;
_remoteCollections = null;
_remoteWorkflows = null;
}
private void OpenDiffView( string id, string name )
{
if ( !_items.TryGetValue( id, out var state ) ) return;
var window = new DiffViewWindow( name, state.LocalYaml ?? "", state.RemoteYaml ?? "" );
window.Show();
}
[Button( "Docs", Icon = "menu_book" )]
public void OpenDocs()
{
Process.Start( new ProcessStartInfo
{
FileName = "https://sboxcool.com/wiki/network-storage-v3",
UseShellExecute = true
} );
}
[Button( "Setup", Icon = "settings" )]
public void OpenSetup()
{
var window = new SetupWindow();
window.Show();
}
[Button( "Refresh", Icon = "refresh" )]
public void Refresh()
{
SyncToolConfig.Load();
_publishTarget = SyncToolConfig.PublishTarget;
RefreshFileList();
_items.Clear();
_syncLog.Clear();
_scrollY = 0;
_hasCheckedRemote = false;
_remoteEndpoints = null;
_remoteCollections = null;
_remoteWorkflows = null;
_status = SyncToolConfig.IsValid ? "Refreshed" : "Config invalid — check .env";
Update();
_status = SyncToolConfig.IsValid ? "Refreshed" : "Config invalid — check .env";
Update();
}
private static void LogDiffResult( string id, string kind, string localJson, string remoteJson, bool differs, (SyncStatus Status, string Summary, JsonDiffUtilities.ComparisonResult Analysis) classification )
{
// Diff details available in classification.Analysis if needed for debugging
}
private static string TruncateVal( string v, int maxLen = 120 )
{
if ( string.IsNullOrEmpty( v ) ) return "(empty)";
return v.Length <= maxLen ? v : v[..maxLen] + $"… ({v.Length} chars)";
}
} // end class