using System.IO;
using UnityEditor;
using UnityEngine;
namespace Unity.Pipeline.Editor
{
///
/// Custom inspector for the settings asset: shows live server
/// status (accurate running check + actual port, read from the static owner) and offers
/// Start/Stop/Restart buttons, alongside the editable configuration. Watchdog edits are pushed to
/// a running server immediately; port/autoStart apply on the next start.
///
[CustomEditor(typeof(EditorPipelineManager))]
class EditorPipelineManagerEditor : UnityEditor.Editor
{
// Repaint() from inside OnInspectorGUI re-triggers OnInspectorGUI on the next editor tick, so
// a running server redrew this IMGUI-heavy inspector every frame (several ms) while merely
// visible. Repaint from EditorApplication.update at a low rate instead.
private const double StatusRepaintInterval = 0.25;
private double m_NextStatusRepaint;
private void OnEnable()
{
EditorApplication.update += ThrottledStatusRepaint;
}
private void OnDisable()
{
EditorApplication.update -= ThrottledStatusRepaint;
}
private void ThrottledStatusRepaint()
{
var mgr = target as EditorPipelineManager;
if (mgr == null || !(mgr.IsServerRunning || EditorCodeReloadWatcher.IsWatching))
return;
double now = EditorApplication.timeSinceStartup;
if (now < m_NextStatusRepaint)
return;
m_NextStatusRepaint = now + StatusRepaintInterval;
Repaint();
}
/// Draw the default inspector plus live server status and Start/Stop/Restart controls.
public override void OnInspectorGUI()
{
var mgr = (EditorPipelineManager)target;
EditorGUI.BeginChangeCheck();
DrawDefaultInspector();
if (EditorGUI.EndChangeCheck())
{
// Push edited watchdog config to the live server (owned by PipelineServerStartup), so
// changes take effect without a restart.
var server = PipelineServerStartup.Server;
if (server != null)
{
server.WatchdogEnabled = mgr.WatchdogEnabled;
server.WatchdogIntervalSeconds = mgr.WatchdogIntervalSeconds;
server.LogRequestsResponses = mgr.LogRequestsResponses;
}
// Eval-usage telemetry config lives on static fields (recording happens in the
// Runtime command layer, not the server), so push edits there too — applies live.
// Goes through the same ApplyEvalTelemetrySettings the startup/server-start paths
// use, so there is exactly one place that knows the settings->statics mapping.
PipelineServerStartup.ApplyEvalTelemetrySettings(mgr);
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("Status", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.Toggle("Running", mgr.IsServerRunning);
EditorGUILayout.IntField("Actual Port", mgr.ActualPort);
}
EditorGUILayout.Space();
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(mgr.IsServerRunning))
{
if (GUILayout.Button("Start"))
mgr.StartServer();
}
using (new EditorGUI.DisabledScope(!mgr.IsServerRunning))
{
if (GUILayout.Button("Stop"))
mgr.StopServer();
}
if (GUILayout.Button("Restart"))
mgr.RestartServer();
}
DrawCodeReloadWatchSection();
// Live status is kept current by ThrottledStatusRepaint — never Repaint() from here.
}
///
/// Watch the project's Assets folder and re-apply affected [CodeReload] methods on every save,
/// driving . Neither scope, target, nor backend is a
/// choice: the whole Assets tree is watched, each save goes to the connected player(s) if any,
/// else applies in-editor, and both paths run on the interpreter; a read-only row shows the
/// current target resolution. Config is locked while a watch is active.
///
private void DrawCodeReloadWatchSection()
{
EditorGUILayout.Space();
EditorGUILayout.LabelField("Code Reload Interpreter Watch", EditorStyles.boldLabel);
bool watching = EditorCodeReloadWatcher.IsWatching;
serializedObject.Update();
// Not a picker: each save is pushed to the connected player(s) if any, else applied
// in-editor — resolved per save, so plugging in a device mid-watch reroutes the next save.
int connected = EditorCodeReloadWatcher.ConnectedPlayerCount;
using (new EditorGUI.DisabledScope(true))
EditorGUILayout.LabelField(
new GUIContent("Target", "Resolved automatically at each file change: development player(s) if connected, otherwise the reload applies in this editor process. Both run on the interpreter."),
new GUIContent(connected > 0
? $"Player — {connected} connected"
: "In Editor — no player connected"));
// Outside the watching-locked scope: read at each player-connect event, so toggling it
// takes effect for the next connection even while a watch is active.
EditorGUILayout.PropertyField(
serializedObject.FindProperty("m_CodeReloadRepushOnConnect"),
new GUIContent("Re-push On Connect",
"Re-push the watched code-reload state to any player that connects while the watch " +
"is active. A restarted player boots the original build without previously pushed " +
"overrides; this catches it up immediately instead of waiting for the next save."));
serializedObject.ApplyModifiedProperties();
using (new EditorGUILayout.HorizontalScope())
{
using (new EditorGUI.DisabledScope(watching))
{
// The UI always watches the whole Assets tree; StartWatch itself accepts any
// file or folder for CLI/API callers.
if (GUILayout.Button("Start Watching"))
EditorCodeReloadWatcher.StartWatch(Path.GetFullPath(Application.dataPath),
isFolder: true);
}
using (new EditorGUI.DisabledScope(!watching))
{
if (GUILayout.Button("Stop Watching"))
EditorCodeReloadWatcher.StopWatch();
}
}
if (watching)
EditorGUILayout.HelpBox(
"Asset auto-refresh is disabled while watching, so saved scripts code-reload instead of " +
"triggering a domain reload. New or changed assets won't import until you refresh manually " +
"(Cmd/Ctrl+R); Stop Watching restores your auto-refresh setting.",
MessageType.Warning);
using (new EditorGUI.DisabledScope(true))
{
// Read WatchPath once: it goes null the instant the watch stops (domain reload / StopWatch),
// which can race the `watching` captured at the top of this method — guard against null here.
var watchPath = EditorCodeReloadWatcher.WatchPath;
// The live Target row above shows where saves currently go; this line covers what is watched.
string status = watching && !string.IsNullOrEmpty(watchPath)
? $"{(EditorCodeReloadWatcher.IsFolder ? "folder" : "file")} · {Path.GetFileName(watchPath.TrimEnd('/', '\\'))}"
: "idle";
EditorGUILayout.LabelField("Watching", status);
if (watching)
{
// Liveness: saves applying while the event count stays flat means the OS file
// watcher is dead and the reconcile poll is carrying the watch.
EditorGUILayout.LabelField("Watcher Events",
$"{EditorCodeReloadWatcher.FsEventCount}{Ago(EditorCodeReloadWatcher.LastFsEventUtc)}");
EditorGUILayout.LabelField("Last Apply",
EditorCodeReloadWatcher.LastApplyFile is string f
? $"{f}{Ago(EditorCodeReloadWatcher.LastApplyUtc)}"
: "none yet");
}
}
}
private static string Ago(System.DateTime? utc)
{
if (utc == null) return "";
var s = (System.DateTime.UtcNow - utc.Value).TotalSeconds;
return s < 1 ? " · just now"
: s < 120 ? $" · {s:0}s ago"
: s < 7200 ? $" · {s / 60:0}m ago"
: $" · {s / 3600:0}h ago";
}
}
}