Files
trail-into-darkness/Packages/com.jovian.logger/Editor/CustomConsole.cs

2225 lines
105 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
#if UNITY_6000_3_OR_NEWER
using UnityEditor.Toolbars;
#endif
namespace Jovian.Logger {
public class CustomConsole : EditorWindow {
private static List<LogEntry> logs = new List<LogEntry>();
private static readonly ConcurrentQueue<LogEntry> pendingLogs = new ConcurrentQueue<LogEntry>();
private static int lastLogCount;
private Vector2 scrollPosition;
private LogCategory selectedLogCategory = (LogCategory)(1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192);
private JovianLogType selectedJovianLogType = JovianLogType.Spam;
private string searchQuery = "";
private LogSourceFilter selectedLogSource = LogSourceFilter.Both;
private bool showSpam = true;
private bool showInfo = true;
private bool showWarning = true;
private bool showError = true;
private bool showAssert = true;
private bool showException = true;
private static bool errorPause;
private static bool persistLogs = true;
private bool collapse;
private bool autoScroll = true;
private bool showTimestamps;
private bool showFrameCount;
private bool showColumns;
private bool useRegexSearch;
private Regex searchRegex;
private GUIStyle smallButtonStyle;
private bool remoteLogging;
private string remoteClientName = "";
private bool newestFirst = true;
private static HashSet<string> ignoredLogKeys = new HashSet<string>();
private GUIStyle pinnedBoxStyle;
// Watch mode
private static readonly Dictionary<string, WatchEntry> watchEntries = new Dictionary<string, WatchEntry>();
private static readonly List<string> watchKeyOrder = new List<string>(); // insertion order
private bool showWatchPanel = true;
private enum SortColumn { None, Frame, Timestamp, Type, Source, File, Class, Category, Message }
private SortColumn sortColumn = SortColumn.None;
private bool sortAscending = true;
private enum LogSourceFilter { Both, Custom, Unity, Remote }
private static LoggerSettings settings;
private static CustomConsole window;
// ── Performance: cached filtering ──
private bool filterDirty = true;
private int lastFilteredLogCount;
private readonly List<(LogEntry log, int collapseCount)> cachedPinned = new();
private readonly List<(LogEntry log, int collapseCount)> cachedUnpinned = new();
// ── Performance: incremental type counts ──
private int[] typeCounts = new int[6];
private int lastCountedLogCount;
// ── Performance: GUIStyle cache ──
private GUIStyle[] logStyleCache;
private bool logStyleCacheDirty = true;
// ── Performance: virtualized scroll ──
private const float RowHeight = 30f;
private bool hasBlinkingIndicators;
// Cached during Layout to ensure the same row count is drawn during Repaint,
// preventing "Invalid GUILayout state" errors from mismatched Begin/End calls.
private int _virtPinnedCount;
private int _virtUnpinnedCount;
private int _virtFirst;
private int _virtLast;
// Subtle tint drawn over the default box background for logs created after the last compile
private static readonly Color newLogTint = new Color(1f, 1f, 1f, 0.06f);
private float viewportHeight;
// ── Detail pane (stack trace) ──
private LogEntry selectedLog;
private float detailPaneHeight = 150f;
private Vector2 detailPaneScroll;
private bool isDraggingDetailPane;
private GUIStyle stackTraceLinkStyle;
private GUIStyle stackTraceDimStyle;
private GUIStyle stackTraceNormalStyle;
private const float MinDetailPaneHeight = 60f;
private const float MaxDetailPaneHeight = 500f;
private const float DetailPaneDragHandleHeight = 6f;
// ── Older-log collapse (Persist mode) ──
private bool olderLogsCollapsed = true;
private int _filteredOlderCount; // old logs hidden behind the collapse bar
private static string PrefsPrefix => "CustomConsole_" + Application.dataPath.GetHashCode().ToString("X") + "_";
[InitializeOnLoadMethod]
private static void Register() {
persistLogs = EditorPrefs.GetBool(PrefsPrefix + "persistLogs", true);
LoggerUtility.FormattedLogCallback += HandleCustomLog;
LoggerUtility.WatchCallback += HandleWatch;
LoggerUtility.UnwatchCallback += HandleUnwatch;
Application.logMessageReceived += HandleUnityLog;
AssemblyReloadEvents.beforeAssemblyReload += SaveSessionLogs;
RestoreSessionLogs();
CaptureExistingConsoleLogs();
if (!persistLogs) {
// Persist off: keep compile errors/warnings (they're relevant to the
// current compilation) but clear everything else for a clean slate.
// On the 2nd reload of the same recompile (reloadCount > 0), also keep
// logs marked isNew — they came from the 1st reload and are part of the
// same recompile, so they should appear as current logs, not be cached.
logs.RemoveAll(log => !log.isCompileError && !log.isCompileWarning && !log.isNew);
}
LoadIgnoreList();
EditorApplication.delayCall += () => {
settings = LoggerUtility.LoadCustomLoggerSettings();
if(window != null) {
window.logStyleCacheDirty = true;
}
// Re-capture after all InitializeOnLoadMethod and DidReloadScripts
// callbacks have finished, catching any logs that fired before Register().
CaptureExistingConsoleLogs();
// Reset reload counter after the final domain reload completes.
// This ensures the next recompile starts fresh at count 0.
reloadCount = 0;
SessionState.SetInt(SessionStateReloadKey, 0);
};
}
private static void HandleCustomLog((JovianLogType logType, LogCategory logCategory, string message) log) {
// Stack trace comes later via HandleUnityLog when Debug.unityLogger.Log fires
var entry = new LogEntry(log.logType, log.logCategory, log.message, "", isCustomLog: true);
if(!LoggerUtility.IsMainThread) {
pendingLogs.Enqueue(entry);
return;
}
logs.Add(entry);
if (errorPause && EditorApplication.isPlaying && log.logType is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert) {
EditorApplication.isPaused = true;
}
window?.Repaint();
}
private static void HandleUnityLog(string condition, string stackTrace, UnityEngine.LogType type) {
// Custom-formatted messages: patch the stack trace onto the existing entry
if (IsCustomFormattedMessage(condition)) {
bool patched = false;
if (!string.IsNullOrEmpty(stackTrace)) {
for (int i = logs.Count - 1; i >= 0; i--) {
if (logs[i].isCustomLog && string.IsNullOrEmpty(logs[i].stackTrace)) {
logs[i].stackTrace = stackTrace;
var info = ExtractCallerInfo(stackTrace);
logs[i].sourceFile = info.file;
logs[i].className = info.className;
patched = true;
break;
}
}
}
// If HandleCustomLog didn't capture this (e.g. console opened after the log fired),
// create a new entry from the Unity message so it's not silently dropped.
if (!patched) {
var cleanMsg = StripRichTextTags(condition);
var fType = MapUnityLogType(type);
var entr = new LogEntry(fType, LogCategory.General, cleanMsg, stackTrace ?? "", isCustomLog: true);
var callerInfo = ExtractCallerInfo(stackTrace ?? "");
entr.sourceFile = callerInfo.file;
entr.className = callerInfo.className;
logs.Add(entr);
window?.Repaint();
}
return;
}
var CustomType = MapUnityLogType(type);
var cleanCondition = StripRichTextTags(condition);
var message = cleanCondition;
if (type is UnityEngine.LogType.Error or UnityEngine.LogType.Assert or UnityEngine.LogType.Exception && !string.IsNullOrEmpty(stackTrace)) {
message = $"{cleanCondition}\n{stackTrace}";
}
var entry = new LogEntry(CustomType, LogCategory.General, message, stackTrace ?? "", isCustomLog: false);
if (type == UnityEngine.LogType.Error && CompileErrorRegex.IsMatch(condition)) {
entry.isCompileError = true;
} else if (type == UnityEngine.LogType.Warning && CompileWarningRegex.IsMatch(condition)) {
entry.isCompileWarning = true;
}
logs.Add(entry);
if (errorPause && EditorApplication.isPlaying && type is UnityEngine.LogType.Error or UnityEngine.LogType.Assert or UnityEngine.LogType.Exception) {
EditorApplication.isPaused = true;
}
window?.Repaint();
}
private static void HandleWatch((string key, string value, LogCategory category) watch) {
if (watchEntries.TryGetValue(watch.key, out var existing)) {
existing.value = watch.value;
existing.timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
existing.frameCount = Time.frameCount;
existing.updateCount++;
} else {
var entry = new WatchEntry {
key = watch.key,
value = watch.value,
logCategory = watch.category,
timestamp = DateTime.Now.ToString("HH:mm:ss.fff"),
frameCount = Time.frameCount,
updateCount = 1,
};
watchEntries[watch.key] = entry;
watchKeyOrder.Add(watch.key);
}
window?.Repaint();
}
private static void HandleUnwatch(string key) {
if (watchEntries.Remove(key)) {
watchKeyOrder.Remove(key);
window?.Repaint();
}
}
// ── Retroactive log capture via internal LogEntries API ──
// Other [InitializeOnLoadMethod] callbacks may have logged messages before
// our handlers were registered. Read Unity's internal console buffer via
// reflection and add any entries we missed.
private static Type s_LogEntriesType;
private static Type s_LogEntryType;
private static MethodInfo s_StartGettingEntries;
private static MethodInfo s_EndGettingEntries;
private static MethodInfo s_GetEntryInternal;
private static FieldInfo f_leMessage;
private static FieldInfo f_leMode;
private static FieldInfo f_leCallstackStart;
private static bool s_reflectionReady;
private static void InitLogEntriesReflection() {
if (s_reflectionReady) return;
s_reflectionReady = true;
var asm = Assembly.GetAssembly(typeof(EditorApplication));
s_LogEntriesType = asm.GetType("UnityEditor.LogEntries");
s_LogEntryType = asm.GetType("UnityEditor.LogEntry");
if (s_LogEntriesType == null || s_LogEntryType == null) return;
s_StartGettingEntries = s_LogEntriesType.GetMethod("StartGettingEntries", BindingFlags.Static | BindingFlags.Public);
s_EndGettingEntries = s_LogEntriesType.GetMethod("EndGettingEntries", BindingFlags.Static | BindingFlags.Public);
s_GetEntryInternal = s_LogEntriesType.GetMethod("GetEntryInternal", BindingFlags.Static | BindingFlags.Public);
f_leMessage = s_LogEntryType.GetField("message", BindingFlags.Instance | BindingFlags.Public);
f_leMode = s_LogEntryType.GetField("mode", BindingFlags.Instance | BindingFlags.Public);
f_leCallstackStart = s_LogEntryType.GetField("callstackTextStartUTF16", BindingFlags.Instance | BindingFlags.Public);
}
// LogMessageFlags bitmask categories
private const int LMF_Error = (1 << 0) | (1 << 1) | (1 << 4) | (1 << 6) | (1 << 8) | (1 << 11) | (1 << 17) | (1 << 21);
private const int LMF_Warning = (1 << 7) | (1 << 9) | (1 << 12);
private static void CaptureExistingConsoleLogs() {
InitLogEntriesReflection();
if (s_StartGettingEntries == null || s_GetEntryInternal == null || s_EndGettingEntries == null) return;
// Build a map of message first-lines we already have so we can
// skip true duplicates but mark restored (old) entries as "new"
// when they reappear in Unity's console (i.e. they fired again this session).
var existing = new Dictionary<string, LogEntry>();
for (int i = 0; i < logs.Count; i++) {
string msg = logs[i].message;
int nl = msg.IndexOf('\n');
string key = nl >= 0 ? msg.Substring(0, nl) : msg;
existing.TryAdd(key, logs[i]);
}
int count = (int)s_StartGettingEntries.Invoke(null, null);
try {
for (int i = 0; i < count; i++) {
object entry = Activator.CreateInstance(s_LogEntryType);
bool ok = (bool)s_GetEntryInternal.Invoke(null, new object[] { i, entry });
if (!ok) continue;
string fullMessage = (string)f_leMessage.GetValue(entry);
int mode = (int)f_leMode.GetValue(entry);
int stackStart = f_leCallstackStart != null ? (int)f_leCallstackStart.GetValue(entry) : 0;
string rawLogText = stackStart > 0 ? fullMessage.Substring(0, stackStart).TrimEnd() : fullMessage;
string stackTrace = stackStart > 0 ? fullMessage.Substring(stackStart) : "";
// Strip rich text tags (e.g. <color=#hex>...</color>) for clean display
string logText = StripRichTextTags(rawLogText);
// Skip if we already have this entry, but mark restored entries
// as "new" since they fired again in this session.
string firstLine = logText;
int nl = firstLine.IndexOf('\n');
if (nl >= 0) firstLine = firstLine.Substring(0, nl);
if (existing.TryGetValue(firstLine, out var existingEntry)) {
if (existingEntry != null && !existingEntry.isNew) {
existingEntry.isNew = true;
existingEntry.timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
existingEntry.frameCount = LoggerUtility.FrameCount;
}
continue;
}
existing.Add(firstLine, null); // null — no entry to promote later
// Determine log type from mode flags
JovianLogType fType;
if ((mode & LMF_Error) != 0) fType = JovianLogType.Error;
else if ((mode & LMF_Warning) != 0) fType = JovianLogType.Warning;
else fType = JovianLogType.Info;
// Check if this is a Custom-formatted message (use raw text — detection looks for <color=> tags)
bool isCustom = IsCustomFormattedMessage(rawLogText);
// For Custom-formatted messages, parse the actual type from the prefix
// (the mode flags only distinguish Error/Warning/Log, losing Spam/Assert/Exception)
if (isCustom) {
if (logText.StartsWith("SPAM -> ")) fType = JovianLogType.Spam;
else if (logText.StartsWith("INFO -> ")) fType = JovianLogType.Info;
else if (logText.StartsWith("WARNING -> ")) fType = JovianLogType.Warning;
else if (logText.StartsWith("ERROR -> ")) fType = JovianLogType.Error;
else if (logText.StartsWith("ASSERT -> ")) fType = JovianLogType.Assert;
else if (logText.StartsWith("EXCEPTION -> ")) fType = JovianLogType.Exception;
}
var logEntry = new LogEntry(fType, LogCategory.General, logText, stackTrace, isCustomLog: isCustom);
if ((mode & (1 << 11)) != 0) logEntry.isCompileError = true; // kScriptCompileError
if ((mode & (1 << 12)) != 0) logEntry.isCompileWarning = true; // kScriptCompileWarning
var info = ExtractCallerInfo(stackTrace);
logEntry.sourceFile = info.file;
logEntry.className = info.className;
logs.Add(logEntry);
}
}
catch (Exception e) {
Debug.LogWarning($"[CustomConsole] Failed to capture existing console logs: {e.Message}");
}
finally {
s_EndGettingEntries.Invoke(null, null);
}
}
private static readonly string[] CustomPrefixes = {
"INFO -> ", "ERROR -> ", "WARNING -> ",
"EXCEPTION -> ", "ASSERT -> ", "SPAM -> "
};
private static bool IsCustomFormattedMessage(string condition) {
foreach (var prefix in CustomPrefixes) {
if (condition.StartsWith(prefix, System.StringComparison.Ordinal)) {
return true;
}
}
if (condition.StartsWith("<color=#", System.StringComparison.Ordinal) && condition.Length > 22) {
var afterTag = condition.AsSpan(15);
foreach (var prefix in CustomPrefixes) {
if (afterTag.StartsWith(prefix.AsSpan(), System.StringComparison.Ordinal)) {
return true;
}
}
}
return false;
}
private static JovianLogType MapUnityLogType(UnityEngine.LogType type) {
return type switch {
UnityEngine.LogType.Error => JovianLogType.Error,
UnityEngine.LogType.Assert => JovianLogType.Assert,
UnityEngine.LogType.Warning => JovianLogType.Warning,
UnityEngine.LogType.Exception => JovianLogType.Exception,
_ => JovianLogType.Info,
};
}
private static readonly string[] LogTypeFilterNames = { "Everything", "Exception", "Assert", "Error", "Warning", "Info" };
private static readonly JovianLogType[] LogTypeFilterValues = {
JovianLogType.Spam, JovianLogType.Exception, JovianLogType.Assert,
JovianLogType.Error, JovianLogType.Warning, JovianLogType.Info
};
private static readonly LogCategory AllCategories = (LogCategory)(1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192);
private void DrawLogCategoryFilter() {
string label = selectedLogCategory == AllCategories ? "Everything" : selectedLogCategory == 0 ? "Nothing" : selectedLogCategory.ToString();
if (GUILayout.Button(label, EditorStyles.toolbarDropDown, GUILayout.Width(150))) {
var menu = new GenericMenu();
menu.AddItem(new GUIContent("Everything"), selectedLogCategory == AllCategories, () => { selectedLogCategory = AllCategories; filterDirty = true; Repaint(); });
menu.AddItem(new GUIContent("Nothing"), selectedLogCategory == 0, () => { selectedLogCategory = 0; filterDirty = true; Repaint(); });
menu.AddSeparator("");
foreach (LogCategory cat in Enum.GetValues(typeof(LogCategory))) {
if (cat == LogCategory.None) continue;
LogCategory c = cat;
bool on = selectedLogCategory.HasFlag(c);
menu.AddItem(new GUIContent(c.ToString()), on, () => {
if (on) selectedLogCategory &= ~c;
else selectedLogCategory |= c;
filterDirty = true;
Repaint();
});
}
menu.ShowAsContext();
}
}
private static JovianLogType DrawLogTypeFilter(JovianLogType current) {
int index = Array.IndexOf(LogTypeFilterValues, current);
if (index < 0) index = 0;
int newIndex = EditorGUILayout.Popup(index, LogTypeFilterNames, EditorStyles.toolbarPopup, GUILayout.Width(100));
return LogTypeFilterValues[newIndex];
}
[MenuItem("Fidelit&y/&Utility/Custom Logger/Custom Console", false, 2)]
public static void ShowWindow() {
window = GetWindow<CustomConsole>("Custom Console");
window.Show();
}
private void RefreshWindow() {
// Reset all internal state and caches without closing the window
SavePrefs();
// Clear cached styles so they're rebuilt
smallButtonStyle = null;
pinnedBoxStyle = null;
logStyleCache = null;
logStyleCacheDirty = true;
stackTraceLinkStyle = null;
stackTraceDimStyle = null;
stackTraceNormalStyle = null;
// Reset filter/view state
filterDirty = true;
lastFilteredLogCount = 0;
lastCountedLogCount = 0;
Array.Clear(typeCounts, 0, typeCounts.Length);
cachedPinned.Clear();
cachedUnpinned.Clear();
selectedLog = null;
scrollPosition = Vector2.zero;
detailPaneScroll = Vector2.zero;
// Reload settings and prefs
settings = LoggerUtility.LoadCustomLoggerSettings();
LoadPrefs();
Repaint();
}
private void OnEnable() {
window = this;
LoadPrefs();
RemoteLogReceiver.OnRemoteLog += HandleRemoteLog;
RemoteLogReceiver.OnRemoteWatch += HandleRemoteWatch;
RemoteLogReceiver.OnRemoteUnwatch += HandleRemoteUnwatch;
RemoteLogReceiver.OnClientConnected += HandleRemoteClientConnected;
RemoteLogReceiver.OnClientDisconnected += HandleRemoteClientDisconnected;
}
private void OnDisable() {
SavePrefs();
RemoteLogReceiver.OnRemoteLog -= HandleRemoteLog;
RemoteLogReceiver.OnRemoteWatch -= HandleRemoteWatch;
RemoteLogReceiver.OnRemoteUnwatch -= HandleRemoteUnwatch;
RemoteLogReceiver.OnClientConnected -= HandleRemoteClientConnected;
RemoteLogReceiver.OnClientDisconnected -= HandleRemoteClientDisconnected;
if (remoteLogging) {
RemoteLogReceiver.Stop();
remoteLogging = false;
}
if (window == this) {
window = null;
}
}
private void HandleRemoteLog(RemoteLogReceiver.RemoteLogEntry remote) {
var entry = new LogEntry(remote.type, remote.logCategory, remote.message, remote.stackTrace, remote.isCustomLog);
entry.timestamp = remote.timestamp;
entry.frameCount = remote.frameCount;
entry.isRemote = true;
logs.Add(entry);
if (errorPause && EditorApplication.isPlaying && remote.type is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert) {
EditorApplication.isPaused = true;
}
Repaint();
}
private void HandleRemoteWatch(RemoteLogReceiver.RemoteWatchEntry remote) {
if (watchEntries.TryGetValue(remote.key, out var existing)) {
existing.value = remote.value;
existing.timestamp = remote.timestamp;
existing.frameCount = remote.frameCount;
existing.updateCount++;
} else {
var entry = new WatchEntry {
key = remote.key,
value = remote.value,
logCategory = remote.logCategory,
timestamp = remote.timestamp,
frameCount = remote.frameCount,
updateCount = 1,
isRemote = true,
};
watchEntries[remote.key] = entry;
watchKeyOrder.Add(remote.key);
}
Repaint();
}
private void HandleRemoteUnwatch(string key) {
if (watchEntries.Remove(key)) {
watchKeyOrder.Remove(key);
Repaint();
}
}
private void HandleRemoteClientConnected(string clientName) {
remoteClientName = clientName;
Repaint();
}
private void HandleRemoteClientDisconnected() {
remoteClientName = "";
Repaint();
}
private void SavePrefs() {
string p = PrefsPrefix;
EditorPrefs.SetBool(p + "showSpam", showSpam);
EditorPrefs.SetBool(p + "showInfo", showInfo);
EditorPrefs.SetBool(p + "showWarning", showWarning);
EditorPrefs.SetBool(p + "showError", showError);
EditorPrefs.SetBool(p + "showAssert", showAssert);
EditorPrefs.SetBool(p + "showException", showException);
EditorPrefs.SetBool(p + "errorPause", errorPause);
EditorPrefs.SetBool(p + "collapse", collapse);
EditorPrefs.SetBool(p + "autoScroll", autoScroll);
EditorPrefs.SetBool(p + "showTimestamps", showTimestamps);
EditorPrefs.SetBool(p + "showFrameCount", showFrameCount);
EditorPrefs.SetBool(p + "showColumns", showColumns);
EditorPrefs.SetBool(p + "useRegexSearch", useRegexSearch);
EditorPrefs.SetInt(p + "selectedLogSource", (int)selectedLogSource);
EditorPrefs.SetInt(p + "selectedLogType", (int)selectedJovianLogType);
EditorPrefs.SetInt(p + "selectedLogCategory", (int)selectedLogCategory);
EditorPrefs.SetInt(p + "sortColumn", (int)sortColumn);
EditorPrefs.SetBool(p + "sortAscending", sortAscending);
EditorPrefs.SetString(p + "searchQuery", searchQuery);
EditorPrefs.SetBool(p + "newestFirst", newestFirst);
EditorPrefs.SetBool(p + "showWatchPanel", showWatchPanel);
EditorPrefs.SetBool(p + "persistLogs", persistLogs);
EditorPrefs.SetFloat(p + "detailPaneHeight", detailPaneHeight);
EditorPrefs.SetBool(p + "olderLogsCollapsed", olderLogsCollapsed);
}
private void LoadPrefs() {
string p = PrefsPrefix;
if (!EditorPrefs.HasKey(p + "showSpam")) return; // No saved prefs yet
showSpam = EditorPrefs.GetBool(p + "showSpam", true);
showInfo = EditorPrefs.GetBool(p + "showInfo", true);
showWarning = EditorPrefs.GetBool(p + "showWarning", true);
showError = EditorPrefs.GetBool(p + "showError", true);
showAssert = EditorPrefs.GetBool(p + "showAssert", true);
showException = EditorPrefs.GetBool(p + "showException", true);
errorPause = EditorPrefs.GetBool(p + "errorPause", false);
collapse = EditorPrefs.GetBool(p + "collapse", false);
autoScroll = EditorPrefs.GetBool(p + "autoScroll", true);
showTimestamps = EditorPrefs.GetBool(p + "showTimestamps", false);
showFrameCount = EditorPrefs.GetBool(p + "showFrameCount", false);
showColumns = EditorPrefs.GetBool(p + "showColumns", false);
useRegexSearch = EditorPrefs.GetBool(p + "useRegexSearch", false);
selectedLogSource = (LogSourceFilter)EditorPrefs.GetInt(p + "selectedLogSource", 0);
selectedJovianLogType = (JovianLogType)EditorPrefs.GetInt(p + "selectedLogType", (int)JovianLogType.Spam);
selectedLogCategory = (LogCategory)EditorPrefs.GetInt(p + "selectedLogCategory", (int)AllCategories);
sortColumn = (SortColumn)EditorPrefs.GetInt(p + "sortColumn", 0);
sortAscending = EditorPrefs.GetBool(p + "sortAscending", true);
searchQuery = EditorPrefs.GetString(p + "searchQuery", "");
newestFirst = EditorPrefs.GetBool(p + "newestFirst", true);
showWatchPanel = EditorPrefs.GetBool(p + "showWatchPanel", true);
persistLogs = EditorPrefs.GetBool(p + "persistLogs", true);
detailPaneHeight = EditorPrefs.GetFloat(p + "detailPaneHeight", 150f);
olderLogsCollapsed = EditorPrefs.GetBool(p + "olderLogsCollapsed", true);
if (useRegexSearch) CompileSearchRegex();
}
private void OnGUI() {
while(pendingLogs.TryDequeue(out LogEntry pending)) {
logs.Add(pending);
}
hasBlinkingIndicators = false;
if (smallButtonStyle == null) {
smallButtonStyle = new GUIStyle(EditorStyles.miniButton) { fontSize = 9 };
}
if (pinnedBoxStyle == null) {
pinnedBoxStyle = new GUIStyle("box");
pinnedBoxStyle.border = new RectOffset(2, 2, 2, 2);
pinnedBoxStyle.normal.background = MakePinnedBackground();
}
// Toolbar row 1: Actions & log type icons
GUILayout.BeginHorizontal(EditorStyles.toolbar);
{ bool prevPersist = persistLogs;
string persistLabel = persistLogs ? "Persist \u25CF" : "Persist";
persistLogs = GUILayout.Toggle(persistLogs, new GUIContent(persistLabel, "Toggle log persistence across assembly reloads"), EditorStyles.toolbarButton);
if(persistLogs != prevPersist) {
bool confirmed = EditorUtility.DisplayDialog(
"Toggle Log Persistence",
persistLogs
? "Enable log persistence?\n\nLogs will be kept across assembly reloads."
: "Disable log persistence?\n\nLogs will be saved to the log cache and cleared on each assembly reload.",
"Confirm",
"Cancel");
if(!confirmed) {
persistLogs = prevPersist;
}
} }
if(GUILayout.Button("Clear", EditorStyles.toolbarButton)) {
SaveLogCache();
logs.Clear();
Array.Clear(typeCounts, 0, typeCounts.Length);
lastCountedLogCount = 0;
filterDirty = true;
}
errorPause = GUILayout.Toggle(errorPause, "Error Pause", EditorStyles.toolbarButton);
{ bool prev = collapse;
collapse = GUILayout.Toggle(collapse, "Collapse", EditorStyles.toolbarButton);
if(collapse != prev) filterDirty = true; }
autoScroll = GUILayout.Toggle(autoScroll, "Auto Scroll", EditorStyles.toolbarButton);
GUILayout.Space(6);
GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(1));
GUILayout.Space(6);
showFrameCount = GUILayout.Toggle(showFrameCount, "Frame #", EditorStyles.toolbarButton);
showTimestamps = GUILayout.Toggle(showTimestamps, "Timestamps", EditorStyles.toolbarButton);
{ bool prev = showColumns;
showColumns = GUILayout.Toggle(showColumns, new GUIContent("Columns", "Show column-based display with sortable headers for Type, Source, File, Class, and Category"), EditorStyles.toolbarButton);
if(showColumns != prev) filterDirty = true; }
GUILayout.Space(6);
GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(1));
GUILayout.Space(6);
bool prevRemote = remoteLogging;
string remoteLabel = remoteLogging && RemoteLogReceiver.HasClient
? "Remote ●"
: "Remote";
string remoteTooltip = remoteLogging
? (RemoteLogReceiver.HasClient
? $"Connected: {remoteClientName}\nListening on port {RemoteLogReceiver.Port}"
: $"Listening on port {RemoteLogReceiver.Port}... waiting for device")
: "Enable remote logging to receive logs from device builds.\nAdd CustomRemoteLogSender to your scene.";
remoteLogging = GUILayout.Toggle(remoteLogging, new GUIContent(remoteLabel, remoteTooltip), EditorStyles.toolbarButton);
if(remoteLogging != prevRemote) {
if(remoteLogging) {
RemoteLogReceiver.Start();
}
else {
RemoteLogReceiver.Stop();
remoteClientName = "";
}
}
GUILayout.FlexibleSpace();
// Incremental type counts (O(1) when no new logs)
UpdateTypeCounts();
int spamCount = typeCounts[(int)JovianLogType.Spam];
int infoCount = typeCounts[(int)JovianLogType.Info];
int warnCount = typeCounts[(int)JovianLogType.Warning];
int errCount = typeCounts[(int)JovianLogType.Error];
int assertCount = typeCounts[(int)JovianLogType.Assert];
int exceptionCount = typeCounts[(int)JovianLogType.Exception];
{ bool prev = showSpam;
showSpam = GUILayout.Toggle(showSpam, new GUIContent($" {spamCount}", EditorGUIUtility.IconContent("TreeEditor.Trash").image, "Spam"), EditorStyles.toolbarButton);
if(showSpam != prev) filterDirty = true; }
{ bool prev = showInfo;
showInfo = GUILayout.Toggle(showInfo, new GUIContent($" {infoCount}", EditorGUIUtility.IconContent("console.infoicon.sml").image, "Info"), EditorStyles.toolbarButton);
if(showInfo != prev) filterDirty = true; }
{ bool prev = showWarning;
showWarning = GUILayout.Toggle(showWarning, new GUIContent($" {warnCount}", EditorGUIUtility.IconContent("console.warnicon.sml").image, "Warning"), EditorStyles.toolbarButton);
if(showWarning != prev) filterDirty = true; }
{ bool prev = showError;
showError = GUILayout.Toggle(showError, new GUIContent($" {errCount}", EditorGUIUtility.IconContent("console.erroricon.sml").image, "Error"), EditorStyles.toolbarButton);
if(showError != prev) filterDirty = true; }
{ bool prev = showAssert;
showAssert = GUILayout.Toggle(showAssert, new GUIContent($" {assertCount}", EditorGUIUtility.IconContent("d_DebuggerEnabled").image, "Assert"), EditorStyles.toolbarButton);
if(showAssert != prev) filterDirty = true; }
{ bool prev = showException;
showException = GUILayout.Toggle(showException, new GUIContent($" {exceptionCount}", EditorGUIUtility.IconContent("CollabError").image, "Exception"), EditorStyles.toolbarButton);
if(showException != prev) filterDirty = true; }
GUILayout.EndHorizontal();
// Toolbar row 2: Filters & search
GUILayout.BeginHorizontal(EditorStyles.toolbar);
if(GUILayout.Button(new GUIContent(newestFirst ? "Newest First" : "Oldest First", "Toggle log order between newest first and oldest first"), EditorStyles.toolbarButton)) {
newestFirst = !newestFirst;
filterDirty = true;
}
GUILayout.Label("Log Source:", EditorStyles.miniLabel, GUILayout.Width(68));
{ LogSourceFilter prev = selectedLogSource;
selectedLogSource = (LogSourceFilter)EditorGUILayout.EnumPopup(selectedLogSource, EditorStyles.toolbarPopup, GUILayout.Width(80));
if(selectedLogSource != prev) filterDirty = true; }
GUILayout.Space(10);
GUILayout.Label("Filters:", EditorStyles.miniLabel, GUILayout.Width(42));
{ LogCategory prevCat = selectedLogCategory;
DrawLogCategoryFilter();
if(selectedLogCategory != prevCat) filterDirty = true; }
{ JovianLogType prevType = selectedJovianLogType;
selectedJovianLogType = DrawLogTypeFilter(selectedJovianLogType);
if(selectedJovianLogType != prevType) filterDirty = true; }
GUILayout.Space(10);
string prevQuery = searchQuery;
bool prevRegex = useRegexSearch;
searchQuery = GUILayout.TextField(searchQuery, EditorStyles.toolbarSearchField, GUILayout.Width(200));
useRegexSearch = GUILayout.Toggle(useRegexSearch, new GUIContent(".*", "Regex search\nExample: error|warning\nExample: Player\\d+\nExample: ^Init"), EditorStyles.toolbarButton, GUILayout.Width(25));
if(searchQuery != prevQuery || useRegexSearch != prevRegex) {
CompileSearchRegex();
filterDirty = true;
}
GUILayout.FlexibleSpace();
if (GUILayout.Button("Refresh", EditorStyles.toolbarButton)) {
RefreshWindow();
}
if (GUILayout.Button("Export", EditorStyles.toolbarDropDown)) {
var menu = new GenericMenu();
menu.AddItem(new GUIContent("Export Logs"), false, ExportLogs);
menu.AddItem(new GUIContent("Copy for AI"), false, ExportLogsForAI);
menu.ShowAsContext();
}
if (GUILayout.Button("Log Cache", EditorStyles.toolbarDropDown)) {
var menu = new GenericMenu();
var cachedFiles = GetCachedLogFiles();
if (cachedFiles.Length == 0) {
menu.AddDisabledItem(new GUIContent("No cached logs"));
} else {
foreach (var file in cachedFiles) {
string label = file.CreationTime.ToString("MMM dd, HH:mm:ss");
string path = file.FullName;
menu.AddItem(new GUIContent(label), false, () => OpenCachedLog(path));
}
menu.AddSeparator("");
menu.AddItem(new GUIContent("Clear All Cached Logs"), false, ClearLogCache);
}
menu.ShowAsContext();
}
GUILayout.EndHorizontal();
// Column header row (only in column mode)
if (showColumns) {
GUILayout.BeginHorizontal(EditorStyles.toolbar);
if (collapse) DrawColumnHeader("#", SortColumn.None, 30);
if (showFrameCount) DrawColumnHeader("Frame", SortColumn.Frame, 50);
if (showTimestamps) DrawColumnHeader("Time", SortColumn.Timestamp, 75);
DrawColumnHeader("Type", SortColumn.Type, 65);
DrawColumnHeader("Src", SortColumn.Source, 35);
DrawColumnHeader("File", SortColumn.File, 120);
DrawColumnHeader("Class", SortColumn.Class, 120);
DrawColumnHeader("Category", SortColumn.Category, 90);
DrawColumnHeader("Message", SortColumn.Message, 0);
GUILayout.Label("", EditorStyles.toolbarButton, GUILayout.Width(88));
GUILayout.EndHorizontal();
}
// Watch panel
if (watchEntries.Count > 0) {
DrawWatchPanel();
}
// Scrollable Log List
if(autoScroll && logs.Count != lastLogCount) {
scrollPosition = newestFirst ? Vector2.zero : new Vector2(0, float.MaxValue);
lastLogCount = logs.Count;
}
RebuildFilteredListIfDirty();
// Cache visible range during Layout so that Repaint draws the exact same
// number of rows. A mismatch causes "Invalid GUILayout state" errors because
// each DrawLogRow contains BeginHorizontal/EndHorizontal pairs.
const float OlderBarHeight = 24f;
if (Event.current.type == EventType.Layout) {
_virtPinnedCount = cachedPinned.Count;
_virtUnpinnedCount = cachedUnpinned.Count;
float pinnedHeight = _virtPinnedCount * RowHeight + (_virtPinnedCount > 0 ? 5f : 0f);
float maxContentY = Mathf.Max(0, _virtUnpinnedCount * RowHeight - viewportHeight);
float scrollY = Mathf.Clamp(scrollPosition.y - pinnedHeight, 0, maxContentY);
_virtFirst = Mathf.Max(0, Mathf.FloorToInt(scrollY / RowHeight));
int visibleCount = Mathf.CeilToInt(viewportHeight / RowHeight) + 2;
_virtLast = Mathf.Min(_virtUnpinnedCount - 1, _virtFirst + visibleCount);
}
scrollPosition = GUILayout.BeginScrollView(scrollPosition);
// Render pinned first (top) — always fully rendered (usually very few)
if(_virtPinnedCount > 0) {
for(int i = 0; i < _virtPinnedCount && i < cachedPinned.Count; i++) {
DrawLogRow(cachedPinned[i].log, cachedPinned[i].collapseCount, isPinned: true);
}
// Visual separator between pinned and unpinned
GUILayout.Space(2);
Rect sepRect = GUILayoutUtility.GetRect(0, 1, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(sepRect, new Color(1f, 0.8f, 0f, 0.5f));
GUILayout.Space(2);
}
// Unpinned: virtualized — only draw visible rows
int firstVisible = _virtFirst;
int lastVisible = _virtLast;
int unpinnedCount = _virtUnpinnedCount;
if(firstVisible > 0) {
GUILayout.Space(firstVisible * RowHeight);
}
for(int i = firstVisible; i <= lastVisible && i < cachedUnpinned.Count; i++) {
DrawLogRow(cachedUnpinned[i].log, cachedUnpinned[i].collapseCount, isPinned: false);
}
int afterCount = unpinnedCount - lastVisible - 1;
if(afterCount > 0) {
GUILayout.Space(afterCount * RowHeight);
}
GUILayout.EndScrollView();
// Capture viewport height on Repaint for virtualization calculations
if(Event.current.type == EventType.Repaint) {
float captured = GUILayoutUtility.GetLastRect().height;
if(captured > 1f) {
viewportHeight = captured;
}
}
// Sticky older-logs bar — outside scroll view, always visible
// Shows when there are hidden older logs OR when expanded and older logs exist
if (persistLogs && _filteredOlderCount > 0) {
DrawOlderLogsBar(OlderBarHeight);
} else if (persistLogs && !olderLogsCollapsed && HasAnyOlderLogs()) {
DrawOlderLogsBar(OlderBarHeight);
}
// Detail pane: stack trace for selected log
DrawDetailPane();
// Jump to latest button
GUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
string jumpLabel = newestFirst ? "↑ Jump to Latest" : "↓ Jump to Latest";
if(GUILayout.Button(jumpLabel, EditorStyles.miniButton, GUILayout.Width(110))) {
scrollPosition = newestFirst ? Vector2.zero : new Vector2(0, float.MaxValue);
}
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
// Keep repainting while blinking compile-error indicators are visible
if (hasBlinkingIndicators) {
Repaint();
}
}
private void DrawLogRow(LogEntry log, int collapseCount, bool isPinned) {
GUIStyle logStyle = GetLogStyle(log);
bool isLong = IsMultiLine(log.message, MaxCollapsedLines);
bool isExpanded = log.expanded && isLong;
GUIStyle rowStyle = isPinned ? pinnedBoxStyle : (GUIStyle)"box";
Rect rowRect = isExpanded
? EditorGUILayout.BeginHorizontal(rowStyle)
: EditorGUILayout.BeginHorizontal(rowStyle, GUILayout.Height(RowHeight));
// Draw a subtle lighter background for logs created after the last assembly reload
if (log.isNew && Event.current.type == EventType.Repaint) {
EditorGUI.DrawRect(rowRect, newLogTint);
}
// Highlight selected row
if (selectedLog == log && Event.current.type == EventType.Repaint) {
EditorGUI.DrawRect(rowRect, new Color(0.17f, 0.36f, 0.53f, 0.4f));
}
// Tooltip: "Double-click to open source"
if (!string.IsNullOrEmpty(log.stackTrace) || log.isCompileError || log.isCompileWarning) {
GUI.Label(rowRect, new GUIContent("", "Double-click to open source"));
}
// Double-click: open source file in IDE
if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && Event.current.clickCount == 2 && rowRect.Contains(Event.current.mousePosition)) {
Event.current.Use();
string sourceRef = (log.isCompileError || log.isCompileWarning) && !HasSourceLocation(log.stackTrace) ? log.message : log.stackTrace;
if (HasSourceLocation(sourceRef)) {
OpenSourceLocation(sourceRef);
}
}
// Single-click: select log to show stack trace in detail pane
else if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && Event.current.clickCount == 1 && rowRect.Contains(Event.current.mousePosition)) {
selectedLog = log;
detailPaneScroll = Vector2.zero;
Repaint();
}
// Intercept right-click before controls can steal it (TextArea has its own context menu)
if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && rowRect.Contains(Event.current.mousePosition)) {
Event.current.Use();
ShowLogContextMenu(log);
}
if (collapse) {
GUILayout.Label(collapseCount > 1 ? collapseCount.ToString() : "", EditorStyles.miniLabel, GUILayout.Width(30));
}
if (showFrameCount) {
GUILayout.Label(log.frameCount.ToString(), EditorStyles.miniLabel, GUILayout.Width(50));
}
if (showTimestamps) {
GUILayout.Label(log.timestamp, EditorStyles.miniLabel, GUILayout.Width(75));
}
if (showColumns) {
GUILayout.Label(log.type.ToString(), logStyle, GUILayout.Width(65));
string srcLabel = log.isRemote ? (log.isCustomLog ? "RF" : "RU") : (log.isCustomLog ? "F" : "U");
GUILayout.Label(srcLabel, EditorStyles.miniLabel, GUILayout.Width(35));
GUILayout.Label(log.sourceFile ?? "", EditorStyles.miniLabel, GUILayout.Width(120));
GUILayout.Label(log.className ?? "", EditorStyles.miniLabel, GUILayout.Width(120));
GUILayout.Label(log.isCustomLog ? log.logCategory.ToString() : "", EditorStyles.miniLabel, GUILayout.Width(90));
}
string displayText = isLong && !log.expanded
? TruncateToLines(log.message, MaxCollapsedLines) + " ..."
: log.message;
EditorGUILayout.TextArea(displayText, logStyle);
if (log.isNew && log.isCompileError) {
float t = Mathf.PingPong((float)EditorApplication.timeSinceStartup * 3f, 1f);
Color blinkColor = Color.Lerp(Color.yellow, Color.red, t);
Rect lineRect = GUILayoutUtility.GetRect(5, RowHeight, GUILayout.Width(5));
EditorGUI.DrawRect(lineRect, blinkColor);
hasBlinkingIndicators = true;
} else if (log.isNew && log.isCompileWarning) {
Rect lineRect = GUILayoutUtility.GetRect(2.5f, RowHeight, GUILayout.Width(2.5f));
EditorGUI.DrawRect(lineRect, Color.yellow);
}
if (isLong) {
if (GUILayout.Button(log.expanded ? "▲" : "▼", smallButtonStyle, GUILayout.Width(18))) {
log.expanded = !log.expanded;
}
}
if (GUILayout.Button("Copy", smallButtonStyle, GUILayout.Width(32))) {
EditorGUIUtility.systemCopyBuffer = log.message;
}
{ string sourceRef = (log.isCompileError || log.isCompileWarning) && !HasSourceLocation(log.stackTrace) ? log.message : log.stackTrace;
using (new EditorGUI.DisabledScope(!HasSourceLocation(sourceRef))) {
if (GUILayout.Button("Show", smallButtonStyle, GUILayout.Width(34))) {
OpenSourceLocation(sourceRef);
}
} }
EditorGUILayout.EndHorizontal();
}
private void EnsureDetailPaneStyles() {
if (stackTraceLinkStyle == null) {
stackTraceLinkStyle = new GUIStyle(EditorStyles.label) {
richText = false,
wordWrap = false,
normal = { textColor = new Color(0.3f, 0.6f, 1f) },
hover = { textColor = new Color(0.5f, 0.75f, 1f) },
padding = new RectOffset(4, 4, 1, 1),
};
}
if (stackTraceDimStyle == null) {
stackTraceDimStyle = new GUIStyle(EditorStyles.label) {
richText = false,
wordWrap = false,
normal = { textColor = new Color(0.5f, 0.5f, 0.5f) },
padding = new RectOffset(4, 4, 1, 1),
};
}
if (stackTraceNormalStyle == null) {
stackTraceNormalStyle = new GUIStyle(EditorStyles.label) {
richText = false,
wordWrap = false,
padding = new RectOffset(4, 4, 1, 1),
};
}
}
private void DrawOlderLogsBar(float barHeight) {
Rect barRect = GUILayoutUtility.GetRect(0, barHeight, GUILayout.ExpandWidth(true));
if (Event.current.type == EventType.Repaint) {
EditorGUI.DrawRect(barRect, new Color(0.18f, 0.18f, 0.18f, 1f));
// Top/bottom border lines
EditorGUI.DrawRect(new Rect(barRect.x, barRect.y, barRect.width, 1), new Color(0.4f, 0.4f, 0.4f, 0.5f));
EditorGUI.DrawRect(new Rect(barRect.x, barRect.yMax - 1, barRect.width, 1), new Color(0.4f, 0.4f, 0.4f, 0.5f));
}
string arrow = olderLogsCollapsed ? "▶" : "▼";
int olderCount = olderLogsCollapsed ? _filteredOlderCount : CountOlderInList();
string label = olderLogsCollapsed
? $"{arrow} {olderCount} older log{(olderCount != 1 ? "s" : "")} hidden — click to expand"
: $"{arrow} Collapse {olderCount} older log{(olderCount != 1 ? "s" : "")}";
GUI.Label(barRect, label, EditorStyles.centeredGreyMiniLabel);
// Handle click
if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && barRect.Contains(Event.current.mousePosition)) {
Event.current.Use();
olderLogsCollapsed = !olderLogsCollapsed;
// When expanding in oldest-first, scroll to bottom so
// the newly revealed old logs (which are above) push the
// view toward the latest entries the user was looking at.
if (!olderLogsCollapsed && !newestFirst) {
scrollPosition = new Vector2(0, float.MaxValue);
}
SavePrefs();
filterDirty = true;
Repaint();
}
EditorGUIUtility.AddCursorRect(barRect, MouseCursor.Link);
}
private bool HasAnyOlderLogs() {
for (int i = 0; i < cachedUnpinned.Count; i++) {
if (!cachedUnpinned[i].log.isNew) return true;
}
return false;
}
private int CountOlderInList() {
int count = 0;
for (int i = 0; i < cachedUnpinned.Count; i++) {
if (!cachedUnpinned[i].log.isNew) count++;
}
return count;
}
private void DrawDetailPane() {
if (selectedLog == null) return;
string trace = selectedLog.stackTrace;
bool hasTrace = !string.IsNullOrEmpty(trace);
EnsureDetailPaneStyles();
// ── Drag handle ──
Rect handleRect = GUILayoutUtility.GetRect(0, DetailPaneDragHandleHeight, GUILayout.ExpandWidth(true));
EditorGUIUtility.AddCursorRect(handleRect, MouseCursor.ResizeVertical);
if (Event.current.type == EventType.Repaint) {
Color handleColor = isDraggingDetailPane
? new Color(0.4f, 0.6f, 0.9f, 0.8f)
: new Color(0.5f, 0.5f, 0.5f, 0.5f);
EditorGUI.DrawRect(handleRect, handleColor);
float cx = handleRect.center.x;
float cy = handleRect.center.y;
Color dotColor = new Color(0.7f, 0.7f, 0.7f, 0.8f);
for (int i = -2; i <= 2; i++) {
EditorGUI.DrawRect(new Rect(cx + i * 8 - 1, cy - 1, 3, 3), dotColor);
}
}
// Handle drag
if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && handleRect.Contains(Event.current.mousePosition)) {
isDraggingDetailPane = true;
Event.current.Use();
}
if (isDraggingDetailPane) {
if (Event.current.type == EventType.MouseDrag) {
detailPaneHeight -= Event.current.delta.y;
detailPaneHeight = Mathf.Clamp(detailPaneHeight, MinDetailPaneHeight, MaxDetailPaneHeight);
Event.current.Use();
Repaint();
}
if (Event.current.type == EventType.MouseUp) {
isDraggingDetailPane = false;
Event.current.Use();
}
}
// ── Detail pane content ──
if (!hasTrace) {
EditorGUILayout.BeginVertical("box", GUILayout.Height(detailPaneHeight));
GUILayout.Label("No stack trace available.", EditorStyles.miniLabel);
GUILayout.FlexibleSpace();
EditorGUILayout.EndVertical();
return;
}
// Header with copy button
GUILayout.BeginHorizontal(EditorStyles.toolbar);
GUILayout.Label("Stack Trace", EditorStyles.miniLabel);
GUILayout.FlexibleSpace();
if (GUILayout.Button("Copy", EditorStyles.toolbarButton, GUILayout.Width(40))) {
EditorGUIUtility.systemCopyBuffer = trace;
}
GUILayout.EndHorizontal();
detailPaneScroll = EditorGUILayout.BeginScrollView(detailPaneScroll, "box", GUILayout.Height(detailPaneHeight));
string[] lines = trace.Split('\n');
foreach (string rawLine in lines) {
string line = rawLine.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) continue;
var match = SourceLocationRegex.Match(line);
bool isInternal = false;
foreach (var internalPath in InternalPaths) {
if (line.Contains(internalPath, StringComparison.Ordinal)) {
isInternal = true;
break;
}
}
if (match.Success) {
string filePath = match.Groups[1].Value;
bool isValidPath = !filePath.StartsWith("<") && filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase);
GUIStyle style = isInternal ? stackTraceDimStyle : (isValidPath ? stackTraceLinkStyle : stackTraceNormalStyle);
string tooltip = isValidPath ? $"Click to open {Path.GetFileName(filePath)}:{match.Groups[2].Value}" : null;
Rect lineRect = GUILayoutUtility.GetRect(new GUIContent(line, tooltip), style, GUILayout.ExpandWidth(true));
// Right-click: copy this line
if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && lineRect.Contains(Event.current.mousePosition)) {
Event.current.Use();
var menu = new GenericMenu();
string capturedLine = line;
menu.AddItem(new GUIContent("Copy Line"), false, () => EditorGUIUtility.systemCopyBuffer = capturedLine);
menu.AddItem(new GUIContent("Copy Full Stack Trace"), false, () => EditorGUIUtility.systemCopyBuffer = trace);
menu.ShowAsContext();
}
// Left-click: open source (only for valid paths)
if (isValidPath) {
EditorGUIUtility.AddCursorRect(lineRect, MouseCursor.Link);
if (Event.current.type == EventType.MouseDown && Event.current.button == 0 && lineRect.Contains(Event.current.mousePosition)) {
Event.current.Use();
int lineNum = int.Parse(match.Groups[2].Value);
UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(filePath, lineNum);
}
}
if (Event.current.type == EventType.Repaint) {
style.Draw(lineRect, new GUIContent(line, tooltip), lineRect.Contains(Event.current.mousePosition), false, false, false);
}
} else {
GUIStyle style = isInternal ? stackTraceDimStyle : stackTraceNormalStyle;
Rect lineRect = GUILayoutUtility.GetRect(new GUIContent(line), style, GUILayout.ExpandWidth(true));
// Right-click: copy
if (Event.current.type == EventType.MouseDown && Event.current.button == 1 && lineRect.Contains(Event.current.mousePosition)) {
Event.current.Use();
var menu = new GenericMenu();
string capturedLine = line;
menu.AddItem(new GUIContent("Copy Line"), false, () => EditorGUIUtility.systemCopyBuffer = capturedLine);
menu.AddItem(new GUIContent("Copy Full Stack Trace"), false, () => EditorGUIUtility.systemCopyBuffer = trace);
menu.ShowAsContext();
}
if (Event.current.type == EventType.Repaint) {
style.Draw(lineRect, line, false, false, false, false);
}
}
}
EditorGUILayout.EndScrollView();
}
private void DrawWatchPanel() {
// Header bar
GUILayout.BeginHorizontal(EditorStyles.toolbar);
showWatchPanel = EditorGUILayout.Foldout(showWatchPanel, $"Watch ({watchEntries.Count})", true, EditorStyles.foldout);
GUILayout.FlexibleSpace();
if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(40))) {
watchEntries.Clear();
watchKeyOrder.Clear();
}
GUILayout.EndHorizontal();
if (!showWatchPanel) return;
// Watch rows
var watchBg = EditorGUIUtility.isProSkin ? new Color(0.18f, 0.22f, 0.28f, 1f) : new Color(0.85f, 0.92f, 1f, 1f);
foreach (var key in watchKeyOrder) {
if (!watchEntries.TryGetValue(key, out var entry)) continue;
var rect = EditorGUILayout.BeginHorizontal("box");
EditorGUI.DrawRect(rect, watchBg);
GUILayout.Label(entry.key, EditorStyles.boldLabel, GUILayout.Width(150));
GUILayout.Label(entry.value, EditorStyles.label);
GUILayout.FlexibleSpace();
if (showTimestamps) {
GUILayout.Label(entry.timestamp, EditorStyles.miniLabel, GUILayout.Width(75));
}
if (showFrameCount) {
GUILayout.Label(entry.frameCount.ToString(), EditorStyles.miniLabel, GUILayout.Width(50));
}
GUILayout.Label($"×{entry.updateCount}", EditorStyles.miniLabel, GUILayout.Width(40));
if (entry.isRemote) {
GUILayout.Label("R", EditorStyles.miniLabel, GUILayout.Width(14));
}
if (GUILayout.Button("×", smallButtonStyle, GUILayout.Width(18))) {
watchEntries.Remove(key);
watchKeyOrder.Remove(key);
GUILayout.EndHorizontal();
break; // collection modified
}
GUILayout.EndHorizontal();
}
// Separator
GUILayout.Space(1);
var sepRect = GUILayoutUtility.GetRect(0, 1, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(sepRect, new Color(0.4f, 0.6f, 0.9f, 0.5f));
GUILayout.Space(1);
}
private void DrawColumnHeader(string label, SortColumn column, float width) {
string arrow = sortColumn == column ? (sortAscending ? " ▲" : " ▼") : "";
bool clicked;
if (width > 0)
clicked = GUILayout.Button(label + arrow, EditorStyles.toolbarButton, GUILayout.Width(width));
else
clicked = GUILayout.Button(label + arrow, EditorStyles.toolbarButton);
if(clicked && column != SortColumn.None) {
if(sortColumn == column) {
sortAscending = !sortAscending;
}
else {
sortColumn = column;
sortAscending = true;
}
filterDirty = true;
}
}
private static Texture2D MakePinnedBackground() {
int w = 8, h = 8;
var tex = new Texture2D(w, h);
var border = new Color(1f, 0.75f, 0f, 1f); // gold
var fill = EditorGUIUtility.isProSkin ? new Color(0.25f, 0.23f, 0.15f, 1f) : new Color(1f, 0.97f, 0.85f, 1f);
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
bool isBorder = x == 0 || x == w - 1 || y == 0 || y == h - 1;
tex.SetPixel(x, y, isBorder ? border : fill);
}
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
private static string GetActiveTextSelection() {
var editor = typeof(EditorGUI)
.GetField("activeEditor", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
?.GetValue(null) as TextEditor;
if (editor != null && editor.hasSelection)
return editor.SelectedText;
return null;
}
private void CompileSearchRegex() {
if (!useRegexSearch || string.IsNullOrEmpty(searchQuery)) {
searchRegex = null;
return;
}
try {
searchRegex = new Regex(searchQuery, RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromMilliseconds(50));
} catch (ArgumentException) {
searchRegex = null;
}
}
private static string GetCollapseKey(LogEntry log) {
// First line of the message + source flag
string firstLine = log.message;
int newlineIdx = firstLine.IndexOf('\n');
if(newlineIdx >= 0) firstLine = firstLine.Substring(0, newlineIdx);
return $"{(log.isCustomLog ? "F" : "U")}|{log.type}|{firstLine}";
}
// ── Performance: cached filtering + type counts ──
private void RebuildFilteredListIfDirty() {
if(!filterDirty && logs.Count == lastFilteredLogCount) {
return;
}
cachedPinned.Clear();
cachedUnpinned.Clear();
_filteredOlderCount = 0;
// Build filtered list with collapse counts and per-group metadata
Dictionary<string, int> collapseCounts = null;
Dictionary<string, bool> collapseHasNew = null; // any member isNew?
Dictionary<string, string> collapseLatestTime = null; // latest timestamp
Dictionary<string, int> collapseLatestFrame = null; // latest frame count
HashSet<string> collapseSeenOld = null;
HashSet<string> collapseSeenNew = null;
if(collapse) {
collapseCounts = new Dictionary<string, int>();
collapseHasNew = new Dictionary<string, bool>();
collapseLatestTime = new Dictionary<string, string>();
collapseLatestFrame = new Dictionary<string, int>();
collapseSeenOld = new HashSet<string>();
collapseSeenNew = new HashSet<string>();
for(int i = 0; i < logs.Count; i++) {
LogEntry entry = logs[i];
if(!MatchesFilter(entry)) continue;
string key = GetCollapseKey(entry);
collapseCounts[key] = collapseCounts.GetValueOrDefault(key, 0) + 1;
if (entry.isNew) collapseHasNew[key] = true;
else collapseHasNew.TryAdd(key, false);
if (!collapseLatestTime.TryGetValue(key, out string prev) ||
string.Compare(entry.timestamp, prev, StringComparison.Ordinal) > 0) {
collapseLatestTime[key] = entry.timestamp;
collapseLatestFrame[key] = entry.frameCount;
}
}
}
// First pass: count old vs new among filtered unpinned logs so we
// know whether hiding older logs makes sense (need BOTH old and new).
bool wantHideOlder = persistLogs && olderLogsCollapsed;
int oldCount = 0, newCount = 0;
if (wantHideOlder) {
for (int i = 0; i < logs.Count; i++) {
LogEntry entry = logs[i];
if (!MatchesFilter(entry) || entry.pinned) continue;
if (entry.isNew) newCount++; else oldCount++;
}
}
bool hideOlder = wantHideOlder && oldCount > 0 && newCount > 0;
int start = newestFirst ? logs.Count - 1 : 0;
int end = newestFirst ? -1 : logs.Count;
int step = newestFirst ? -1 : 1;
for(int i = start; i != end; i += step) {
LogEntry entry = logs[i];
if(!MatchesFilter(entry)) continue;
// When hiding older logs, skip them BEFORE collapse dedup so
// their keys don't block newer logs with the same message.
if(hideOlder && !entry.isNew && !entry.pinned) {
_filteredOlderCount++;
continue;
}
int cc = 1;
if(collapse) {
string key = GetCollapseKey(entry);
// When hiding older logs, use separate seen-sets for old vs new
// so that in oldest-first mode an old duplicate doesn't consume
// the key and prevent the new version from appearing.
// When expanded (or not hiding), use a single set — the group
// metadata (isNew, timestamp) already promotes the representative.
if (hideOlder) {
var seen = entry.isNew ? collapseSeenNew : collapseSeenOld;
if(!seen.Add(key)) continue;
} else {
if(!collapseSeenOld.Add(key)) continue;
}
cc = collapseCounts[key];
// Promote the representative entry to reflect the entire group:
// show as "new" if any member is new, use the latest timestamp/frame.
if (collapseHasNew.TryGetValue(key, out bool hasNew) && hasNew)
entry.isNew = true;
if (collapseLatestTime.TryGetValue(key, out string latestTs)) {
entry.timestamp = latestTs;
entry.frameCount = collapseLatestFrame[key];
}
}
if(entry.pinned) {
cachedPinned.Add((entry, cc));
}
else {
cachedUnpinned.Add((entry, cc));
}
}
// When collapse is on, re-sort by latest timestamp so groups with
// recent activity float to the correct position instead of staying
// at the position of their first occurrence.
if(collapse) {
int dir = newestFirst ? -1 : 1;
cachedUnpinned.Sort((a, b) => dir * string.Compare(a.log.timestamp, b.log.timestamp, StringComparison.Ordinal));
}
// Sort if a column is selected (only in column mode)
if(showColumns && sortColumn != SortColumn.None) {
int Compare((LogEntry log, int collapseCount) a, (LogEntry log, int collapseCount) b) {
int cmp = sortColumn switch {
SortColumn.Frame => a.log.frameCount.CompareTo(b.log.frameCount),
SortColumn.Timestamp => string.Compare(a.log.timestamp, b.log.timestamp, StringComparison.Ordinal),
SortColumn.Type => a.log.type.CompareTo(b.log.type),
SortColumn.Source => a.log.isCustomLog.CompareTo(b.log.isCustomLog),
SortColumn.File => string.Compare(a.log.sourceFile, b.log.sourceFile, StringComparison.OrdinalIgnoreCase),
SortColumn.Class => string.Compare(a.log.className, b.log.className, StringComparison.OrdinalIgnoreCase),
SortColumn.Category => a.log.logCategory.CompareTo(b.log.logCategory),
SortColumn.Message => string.Compare(a.log.message, b.log.message, StringComparison.OrdinalIgnoreCase),
_ => 0,
};
return sortAscending ? cmp : -cmp;
}
cachedPinned.Sort(Compare);
cachedUnpinned.Sort(Compare);
}
filterDirty = false;
lastFilteredLogCount = logs.Count;
}
private void UpdateTypeCounts() {
if(logs.Count == 0 && lastCountedLogCount != 0) {
// Logs were cleared
Array.Clear(typeCounts, 0, typeCounts.Length);
lastCountedLogCount = 0;
return;
}
// Count only newly added entries
for(int i = lastCountedLogCount; i < logs.Count; i++) {
int typeIndex = (int)logs[i].type;
if(typeIndex >= 0 && typeIndex < typeCounts.Length) {
typeCounts[typeIndex]++;
}
}
lastCountedLogCount = logs.Count;
}
private void MarkFilterDirty() {
filterDirty = true;
}
private bool MatchesFilter(LogEntry log) {
// Ignore list
if (IsIgnored(log)) return false;
// Source filter
if (selectedLogSource == LogSourceFilter.Custom && !log.isCustomLog) {
return false;
}
if (selectedLogSource == LogSourceFilter.Unity && log.isCustomLog) {
return false;
}
if (selectedLogSource == LogSourceFilter.Remote && !log.isRemote) {
return false;
}
// Per-type toggle filters
if (log.isCustomLog) {
// Custom logs: each toggle maps directly
switch (log.type) {
case JovianLogType.Spam when !showSpam:
case JovianLogType.Info when !showInfo:
case JovianLogType.Warning when !showWarning:
case JovianLogType.Error when !showError:
case JovianLogType.Assert when !showAssert:
case JovianLogType.Exception when !showException:
return false;
}
} else {
// Unity logs: Info->showInfo, Warning->showWarning, Error->showError
switch (log.type) {
case JovianLogType.Info or JovianLogType.Spam when !showInfo:
case JovianLogType.Warning when !showWarning:
case JovianLogType.Error when !showError:
return false;
}
}
// Severity filter
if (selectedJovianLogType < log.type) {
return false;
}
// Category filter (only applies to Custom logs)
if (log.isCustomLog && !selectedLogCategory.HasFlag(log.logCategory)) {
return false;
}
// Search filter
if (!string.IsNullOrEmpty(searchQuery)) {
if (useRegexSearch && searchRegex != null) {
if (!searchRegex.IsMatch(log.message)) return false;
} else {
if (log.message.IndexOf(searchQuery, StringComparison.OrdinalIgnoreCase) < 0) return false;
}
}
return true;
}
private GUIStyle GetLogStyle(LogEntry log) {
if(logStyleCache == null || logStyleCacheDirty) {
RebuildLogStyleCache();
}
int index = (int)log.type * 2 + (log.isCustomLog ? 1 : 0);
return logStyleCache[index];
}
private void RebuildLogStyleCache() {
logStyleCache = new GUIStyle[12]; // 6 log types × 2 (unity/Custom)
for(int typeIdx = 0; typeIdx < 6; typeIdx++) {
JovianLogType jovianLogType = (JovianLogType)typeIdx;
for(int isCustom = 0; isCustom < 2; isCustom++) {
GUIStyle style = new GUIStyle(EditorStyles.label) {
wordWrap = true,
alignment = TextAnchor.UpperLeft,
padding = new RectOffset(0, 0, 3, 0)
};
if(isCustom == 0) {
style.normal.textColor = GetUnityLogColor(jovianLogType);
}
else if(settings != null) {
style.normal.textColor = jovianLogType switch {
JovianLogType.Warning => settings.loggerColors.warningColor,
JovianLogType.Error => settings.loggerColors.errorColor,
JovianLogType.Exception => settings.loggerColors.exceptionColor,
JovianLogType.Assert => settings.loggerColors.assertColor,
JovianLogType.Info => settings.loggerColors.infoColor,
_ => settings.loggerColors.spamColor,
};
}
logStyleCache[typeIdx * 2 + isCustom] = style;
}
}
logStyleCacheDirty = false;
}
private static Color GetUnityLogColor(JovianLogType type) {
if (settings != null) {
return type switch {
JovianLogType.Warning => settings.loggerColors.warningColor,
JovianLogType.Error => settings.loggerColors.errorColor,
JovianLogType.Exception => settings.loggerColors.exceptionColor,
JovianLogType.Assert => settings.loggerColors.assertColor,
_ => Color.white,
};
}
return type switch {
JovianLogType.Warning => Color.yellow,
JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert => Color.red,
_ => Color.white,
};
}
// Matches C# compiler errors: "Assets/Path/File.cs(10,5): error CS0246: ..."
private static readonly Regex CompileErrorRegex = new(
@"\.cs\(\d+,\d+\): error CS\d+:", RegexOptions.Compiled);
// Matches C# compiler warnings: "Assets/Path/File.cs(10,5): warning CS0168: ..."
private static readonly Regex CompileWarningRegex = new(
@"\.cs\(\d+,\d+\): warning CS\d+:", RegexOptions.Compiled);
// Extracts file path (group 1) and line number (group 2) from compile error messages
private static readonly Regex CompileErrorLocationRegex = new(
@"(.+\.cs)\((\d+),\d+\)", RegexOptions.Compiled);
// Matches "(at Assets/Path/File.cs:123)" or "(at /absolute/path/File.cs:123)"
private static readonly Regex SourceLocationRegex = new(
@"\(at\s+(.+?):(\d+)\)", RegexOptions.Compiled);
// Strips Unity rich text tags: <color=#hex>, </color>, <b>, </b>, <i>, </i>, <size=N>, </size>
private static readonly Regex RichTextTagRegex = new(
@"</?(?:color(?:=#?[0-9a-fA-F]+)?|b|i|size(?:=\d+)?)>", RegexOptions.Compiled);
private static string StripRichTextTags(string text) {
if (string.IsNullOrEmpty(text) || text.IndexOf('<') < 0) return text;
return RichTextTagRegex.Replace(text, "");
}
// Stack frames from the logger itself — skip these to find the actual caller
private static readonly string[] InternalPaths = {
"com.resolutiongames.logger/",
"Custom.Logger.",
"UnityEngine.Debug",
"UnityEngine.Logger",
"UnityEngine.DebugLogHandler",
};
private static bool HasSourceLocation(string stackTrace) {
return FindCallerMatch(stackTrace).Success;
}
private static void OpenSourceLocation(string stackTrace) {
var match = FindCallerMatch(stackTrace);
if (!match.Success) return;
string filePath = match.Groups[1].Value;
int line = int.Parse(match.Groups[2].Value);
UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(filePath, line);
}
private static Match FindCallerMatch(string stackTrace) {
if (string.IsNullOrEmpty(stackTrace)) return Match.Empty;
var matches = SourceLocationRegex.Matches(stackTrace);
// Find the lines each match belongs to, skip logger-internal frames
string[] lines = stackTrace.Split('\n');
foreach (string line in lines) {
var match = SourceLocationRegex.Match(line);
if (!match.Success) continue;
bool isInternal = false;
foreach (var internalPath in InternalPaths) {
if (line.Contains(internalPath, StringComparison.Ordinal)) {
isInternal = true;
break;
}
}
if (!isInternal) {
return match;
}
}
// Fallback: return first match if all frames are internal
if (matches.Count > 0) return matches[0];
// Fallback: try compile error format — "Assets/Path/File.cs(10,5): error ..."
var compileMatch = CompileErrorLocationRegex.Match(stackTrace);
return compileMatch.Success ? compileMatch : Match.Empty;
}
private const int MaxCollapsedLines = 2;
private static string TruncateToLines(string text, int maxLines) {
int pos = 0;
for (int line = 0; line < maxLines; line++) {
int next = text.IndexOf('\n', pos);
if (next < 0) return text;
pos = next + 1;
}
return text.Substring(0, pos > 0 ? pos - 1 : text.Length);
}
private static bool IsMultiLine(string text, int maxLines) {
int count = 0;
for (int i = 0; i < text.Length; i++) {
if (text[i] == '\n') {
count++;
if (count >= maxLines) return true;
}
}
return false;
}
// ── Session persistence across domain reloads ────────────────────
private const string SessionStateKey = "CustomConsole_SessionLogs";
private const string SessionStateWatchKey = "CustomConsole_SessionWatch";
private const string SessionStateReloadKey = "CustomConsole_ReloadCount";
// Tracks how many domain reloads have occurred during the current recompile.
// Unity fires two domain reloads per recompile; we use this to detect the 2nd
// reload so we can (a) keep logs from the 1st reload marked as isNew and
// (b) avoid double-caching logs when persist is off.
private static int reloadCount;
[Serializable]
private class SerializedWatch {
public string key;
public string value;
public int logCategory;
public string timestamp;
public int frameCount;
public int updateCount;
public bool isRemote;
}
[Serializable]
private class SerializedWatchList {
public SerializedWatch[] watches;
}
[Serializable]
private class SerializedLog {
public string message;
public string stackTrace;
public string timestamp;
public int frameCount;
public string sourceFile;
public string className;
public int type;
public int logCategory;
public bool isCustomLog;
public bool isRemote;
public bool pinned;
public bool isCompileError;
public bool isCompileWarning;
public bool isNew;
}
private static void SaveSessionLogs() {
// Track reload count so we can detect the 2nd reload of the same recompile.
reloadCount++;
SessionState.SetInt(SessionStateReloadKey, reloadCount);
// Archive to disk cache only on the first reload to avoid double-caching.
if (!persistLogs && reloadCount <= 1) {
SaveLogCache();
}
// Always save to SessionState so RestoreSessionLogs can recover logs
// after reload — even when persist is off, compile errors/warnings are
// kept and non-compile entries are cleared in Register().
if (logs.Count == 0) {
SessionState.SetString(SessionStateKey, "");
} else {
var serialized = new SerializedLog[logs.Count];
for (int i = 0; i < logs.Count; i++) {
var log = logs[i];
serialized[i] = new SerializedLog {
message = log.message,
stackTrace = log.stackTrace,
timestamp = log.timestamp,
frameCount = log.frameCount,
sourceFile = log.sourceFile,
className = log.className,
type = (int)log.type,
logCategory = (int)log.logCategory,
isCustomLog = log.isCustomLog,
isRemote = log.isRemote,
pinned = log.pinned,
isCompileError = log.isCompileError,
isCompileWarning = log.isCompileWarning,
isNew = log.isNew,
};
}
SessionState.SetString(SessionStateKey, JsonUtility.ToJson(new SerializedLogList { logs = serialized }, false));
}
// Save watches (always — watches are real-time monitors, not historical logs)
if (watchEntries.Count == 0) {
SessionState.SetString(SessionStateWatchKey, "");
} else {
var watches = new SerializedWatch[watchKeyOrder.Count];
for (int i = 0; i < watchKeyOrder.Count; i++) {
var w = watchEntries[watchKeyOrder[i]];
watches[i] = new SerializedWatch {
key = w.key,
value = w.value,
logCategory = (int)w.logCategory,
timestamp = w.timestamp,
frameCount = w.frameCount,
updateCount = w.updateCount,
isRemote = w.isRemote,
};
}
SessionState.SetString(SessionStateWatchKey, JsonUtility.ToJson(new SerializedWatchList { watches = watches }, false));
}
}
private static void RestoreSessionLogs() {
// Recover the reload counter from before this domain reload.
reloadCount = SessionState.GetInt(SessionStateReloadKey, 0);
// On the 2nd+ reload of the same recompile (reloadCount > 1), logs restored
// here came from the 1st reload and should still be treated as "new".
// On the 1st reload (reloadCount == 1), old logs must be reset to isNew = false.
bool keepNewStatus = reloadCount > 1;
string json = SessionState.GetString(SessionStateKey, "");
if (!string.IsNullOrEmpty(json)) {
try {
var data = JsonUtility.FromJson<SerializedLogList>(json);
if (data?.logs != null) {
logs.Clear();
foreach (var s in data.logs) {
var entry = new LogEntry(
(JovianLogType)s.type,
(LogCategory)s.logCategory,
s.message,
s.stackTrace,
s.isCustomLog
);
entry.timestamp = s.timestamp;
entry.frameCount = s.frameCount;
entry.sourceFile = s.sourceFile;
entry.className = s.className;
entry.isRemote = s.isRemote;
entry.pinned = s.pinned;
entry.isCompileError = s.isCompileError;
entry.isCompileWarning = s.isCompileWarning;
entry.isNew = keepNewStatus ? s.isNew : false;
logs.Add(entry);
}
}
} catch { }
}
// Restore watches
string watchJson = SessionState.GetString(SessionStateWatchKey, "");
if (!string.IsNullOrEmpty(watchJson)) {
try {
var data = JsonUtility.FromJson<SerializedWatchList>(watchJson);
if (data?.watches != null) {
watchEntries.Clear();
watchKeyOrder.Clear();
foreach (var s in data.watches) {
var entry = new WatchEntry {
key = s.key,
value = s.value,
logCategory = (LogCategory)s.logCategory,
timestamp = s.timestamp,
frameCount = s.frameCount,
updateCount = s.updateCount,
isRemote = s.isRemote,
};
watchEntries[s.key] = entry;
watchKeyOrder.Add(s.key);
}
}
} catch { }
}
}
[Serializable]
private class SerializedLogList {
public SerializedLog[] logs;
}
// ── Ignore List ──────────────────────────────────────────────────
private static string IgnoreListPath {
get {
string projectId = EditorPrefs.GetString(PrefsPrefix + "ignoreListId", "");
if (string.IsNullOrEmpty(projectId)) {
projectId = Application.dataPath.GetHashCode().ToString("X");
EditorPrefs.SetString(PrefsPrefix + "ignoreListId", projectId);
}
return Path.Combine(Path.GetTempPath(), "CustomConsole", $"ignore-list-{projectId}.txt");
}
}
private static string GetIgnoreKey(LogEntry log) {
// First line of message is the unique key for ignoring
string firstLine = log.message;
int nl = firstLine.IndexOf('\n');
if (nl >= 0) firstLine = firstLine.Substring(0, nl);
return firstLine.Trim();
}
private static void LoadIgnoreList() {
ignoredLogKeys.Clear();
string path = IgnoreListPath;
if (!File.Exists(path)) return;
try {
foreach (string line in File.ReadAllLines(path, Encoding.UTF8)) {
if (!string.IsNullOrWhiteSpace(line)) ignoredLogKeys.Add(line);
}
} catch { }
}
private static void SaveIgnoreList() {
string path = IgnoreListPath;
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllLines(path, ignoredLogKeys, Encoding.UTF8);
}
private static void AddToIgnoreList(LogEntry log) {
string key = GetIgnoreKey(log);
if (ignoredLogKeys.Add(key)) {
SaveIgnoreList();
}
}
private static void OpenIgnoreList() {
string path = IgnoreListPath;
if (!File.Exists(path)) {
// Create empty file so user can see it
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(path, "", Encoding.UTF8);
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo {
FileName = path,
UseShellExecute = true,
});
}
private bool IsIgnored(LogEntry log) {
return ignoredLogKeys.Contains(GetIgnoreKey(log));
}
private void ShowLogContextMenu(LogEntry log) {
// Capture selection now — menu callbacks run later when the TextEditor state may be gone
string selection = GetActiveTextSelection();
var menu = new GenericMenu();
menu.AddItem(new GUIContent("Copy"), false, () => {
if (!string.IsNullOrEmpty(selection)) {
EditorGUIUtility.systemCopyBuffer = selection;
} else {
string full = log.message;
if (!string.IsNullOrEmpty(log.stackTrace))
full += "\n" + log.stackTrace;
EditorGUIUtility.systemCopyBuffer = full;
}
});
menu.AddSeparator("");
if(log.pinned) {
menu.AddItem(new GUIContent("Unpin"), false, () => {
log.pinned = false;
filterDirty = true;
Repaint();
});
}
else {
menu.AddItem(new GUIContent("Pin"), false, () => {
log.pinned = true;
filterDirty = true;
Repaint();
});
}
menu.AddSeparator("");
menu.AddItem(new GUIContent("Ignore Forever"), false, () => {
AddToIgnoreList(log);
filterDirty = true;
Repaint();
});
menu.AddItem(new GUIContent("Show Ignore List"), false, OpenIgnoreList);
menu.AddItem(new GUIContent("Reload Ignore List"), false, () => {
LoadIgnoreList();
filterDirty = true;
Repaint();
});
menu.ShowAsContext();
}
// ── Export ────────────────────────────────────────────────────────
private static void ExportLogs() {
if (logs.Count == 0) {
EditorUtility.DisplayDialog("Export Logs", "No logs to export.", "OK");
return;
}
string path = EditorUtility.SaveFilePanel("Export Logs", "", $"Custom-log-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}", "txt");
if (string.IsNullOrEmpty(path)) return;
var sb = new StringBuilder();
foreach (var log in logs) {
string source = log.isRemote
? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity")
: (log.isCustomLog ? "Custom" : "Unity");
sb.AppendLine($"[{log.timestamp}] [F{log.frameCount}] [{log.type}] [{source}] {log.message}");
if (!string.IsNullOrEmpty(log.stackTrace)) {
sb.AppendLine(log.stackTrace);
}
}
File.WriteAllText(path, sb.ToString(), Encoding.UTF8);
}
private static void ExportLogsForAI() {
if (logs.Count == 0) {
EditorUtility.DisplayDialog("Copy for AI", "No logs to copy.", "OK");
return;
}
var sb = new StringBuilder();
sb.AppendLine("# Unity Log Report");
sb.AppendLine();
sb.AppendLine("## Environment");
sb.AppendLine($"- Unity: {Application.unityVersion}");
sb.AppendLine($"- Platform: {Application.platform}");
sb.AppendLine($"- Product: {Application.productName}");
#pragma warning disable CS0618 // Suppress obsolete warning for broad Unity version compatibility
sb.AppendLine($"- Scripting Backend: {PlayerSettings.GetScriptingBackend(EditorUserBuildSettings.selectedBuildTargetGroup)}");
#pragma warning restore CS0618
sb.AppendLine($"- Timestamp: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine();
// Summary counts
int errors = 0, warnings = 0, exceptions = 0, asserts = 0, total = logs.Count;
foreach (var log in logs) {
switch (log.type) {
case JovianLogType.Error: errors++; break;
case JovianLogType.Warning: warnings++; break;
case JovianLogType.Exception: exceptions++; break;
case JovianLogType.Assert: asserts++; break;
}
}
sb.AppendLine("## Summary");
sb.AppendLine($"- Total: {total} | Errors: {errors} | Warnings: {warnings} | Exceptions: {exceptions} | Asserts: {asserts}");
sb.AppendLine();
// Watch variables
if (watchEntries.Count > 0) {
sb.AppendLine("## Watch Variables");
foreach (var key in watchKeyOrder) {
if (watchEntries.TryGetValue(key, out var w)) {
sb.AppendLine($"- **{w.key}** = `{w.value}` (updated {w.updateCount}x, last: {w.timestamp})");
}
}
sb.AppendLine();
}
// Errors and exceptions first (most relevant for debugging)
var critical = new List<LogEntry>();
var other = new List<LogEntry>();
foreach (var log in logs) {
if (log.type is JovianLogType.Error or JovianLogType.Exception or JovianLogType.Assert)
critical.Add(log);
else
other.Add(log);
}
if (critical.Count > 0) {
sb.AppendLine("## Errors & Exceptions");
sb.AppendLine();
foreach (var log in critical) {
AppendAILogEntry(sb, log);
}
}
if (other.Count > 0) {
sb.AppendLine("## Log Messages");
sb.AppendLine();
// Cap non-critical to last 200 to keep clipboard manageable
int start = other.Count > 200 ? other.Count - 200 : 0;
if (start > 0)
sb.AppendLine($"_(showing last 200 of {other.Count} messages)_\n");
for (int i = start; i < other.Count; i++) {
AppendAILogEntry(sb, other[i]);
}
}
EditorGUIUtility.systemCopyBuffer = sb.ToString();
Debug.Log($"[CustomConsole] Copied {total} log entries for AI ({sb.Length} chars)");
}
private static void AppendAILogEntry(StringBuilder sb, LogEntry log) {
string source = log.isRemote
? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity")
: (log.isCustomLog ? "Custom" : "Unity");
sb.AppendLine($"### [{log.type}] {log.timestamp} frame:{log.frameCount}");
sb.AppendLine($"Source: {source} | File: {log.sourceFile ?? "?"} | Class: {log.className ?? "?"}");
if (log.isCustomLog && log.logCategory != LogCategory.None)
sb.AppendLine($"Category: {log.logCategory}");
sb.AppendLine("```");
sb.AppendLine(log.message);
sb.AppendLine("```");
if (!string.IsNullOrEmpty(log.stackTrace)) {
sb.AppendLine("<details><summary>Stack Trace</summary>");
sb.AppendLine();
sb.AppendLine("```");
sb.AppendLine(log.stackTrace.TrimEnd());
sb.AppendLine("```");
sb.AppendLine("</details>");
}
sb.AppendLine();
}
// ── Log Cache ─────────────────────────────────────────────────────
private static readonly string LogCacheDir = Path.Combine(Path.GetTempPath(), "CustomConsole", "LogCache");
private const int MaxCachedFiles = 10;
private static void SaveLogCache() {
if (logs.Count == 0) return;
if (!Directory.Exists(LogCacheDir)) {
Directory.CreateDirectory(LogCacheDir);
}
string fileName = $"log-cache-{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt";
string filePath = Path.Combine(LogCacheDir, fileName);
var sb = new StringBuilder();
sb.AppendLine("=== Custom Console Log Cache ===");
sb.AppendLine($"Saved: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Entries: {logs.Count}");
sb.AppendLine(new string('=', 36));
sb.AppendLine();
foreach (var log in logs) {
string source = log.isRemote
? (log.isCustomLog ? "Remote-Custom" : "Remote-Unity")
: (log.isCustomLog ? "Custom" : "Unity");
sb.AppendLine($"[{log.timestamp}] [{log.type}] [{source}] {log.message}");
if (!string.IsNullOrEmpty(log.stackTrace)) {
sb.AppendLine(log.stackTrace);
}
}
File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8);
// Prune old files beyond limit
var files = new DirectoryInfo(LogCacheDir)
.GetFiles("log-cache-*.txt")
.OrderByDescending(f => f.CreationTime)
.Skip(MaxCachedFiles)
.ToArray();
foreach (var file in files) {
file.Delete();
}
}
private static FileInfo[] GetCachedLogFiles() {
if (!Directory.Exists(LogCacheDir)) return Array.Empty<FileInfo>();
return new DirectoryInfo(LogCacheDir)
.GetFiles("log-cache-*.txt")
.OrderByDescending(f => f.CreationTime)
.Take(MaxCachedFiles)
.ToArray();
}
private static void ClearLogCache() {
if (!Directory.Exists(LogCacheDir)) return;
var files = new DirectoryInfo(LogCacheDir).GetFiles("log-cache-*.txt");
foreach (var file in files) {
try { file.Delete(); } catch { }
}
Debug.Log($"[CustomConsole] Cleared {files.Length} cached log file(s).");
}
private static void OpenCachedLog(string path) {
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo {
FileName = path,
UseShellExecute = true,
});
}
private static (string file, string className) ExtractCallerInfo(string stackTrace) {
var match = FindCallerMatch(stackTrace);
if (!match.Success) return ("", "");
string file = string.Empty;
string filePath = string.Empty;
try {
filePath = match.Groups[1].Value;
file = Path.GetFileName(filePath);
}
catch {
return (string.Empty, string.Empty);
}
// Parse class name from the stack frame line: "Namespace.Class.Method (args) (at file:line)"
string line = match.Value;
int atIdx = stackTrace.IndexOf(match.Value, StringComparison.Ordinal);
if (atIdx > 0) {
// Get the full line containing the match
int lineStart = stackTrace.LastIndexOf('\n', atIdx - 1) + 1;
string fullLine = stackTrace.Substring(lineStart, atIdx - lineStart).Trim();
// fullLine looks like "Namespace.Class.Method (args)"
int parenIdx = fullLine.IndexOf('(');
if (parenIdx > 0) fullLine = fullLine.Substring(0, parenIdx).Trim();
// Now "Namespace.Class.Method" — take second-to-last dot segment
int lastDot = fullLine.LastIndexOf('.');
if (lastDot > 0) {
int prevDot = fullLine.LastIndexOf('.', lastDot - 1);
string cls = prevDot >= 0 ? fullLine.Substring(prevDot + 1, lastDot - prevDot - 1) : fullLine.Substring(0, lastDot);
return (file, cls);
}
}
return (file, "");
}
private class WatchEntry {
public string key;
public string value;
public LogCategory logCategory;
public string timestamp;
public int frameCount;
public int updateCount;
public bool isRemote;
}
private class LogEntry {
public string message;
public string stackTrace;
public string timestamp;
public int frameCount;
public string sourceFile;
public string className;
public JovianLogType type;
public LogCategory logCategory;
public bool isCustomLog;
public bool isRemote;
public bool pinned;
public bool expanded;
public bool isNew = true;
public bool isCompileError;
public bool isCompileWarning;
public LogEntry(JovianLogType type, LogCategory logCategory, string message, string stackTrace, bool isCustomLog) {
this.message = message;
this.stackTrace = stackTrace;
this.timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
this.frameCount = LoggerUtility.FrameCount;
this.type = type;
this.logCategory = logCategory;
this.isCustomLog = isCustomLog;
var info = ExtractCallerInfo(stackTrace);
this.sourceFile = info.file;
this.className = info.className;
}
}
}
#if UNITY_6000_3_OR_NEWER
static class CustomConsoleMainToolbar {
const string k_ElementPath = "Custom/Console";
[MainToolbarElement(k_ElementPath, defaultDockPosition = MainToolbarDockPosition.Right)]
static MainToolbarButton CreateButton() {
return new MainToolbarButton(
new MainToolbarContent("Custom Console", "Open Custom Console"),
() => CustomConsole.ShowWindow()
);
}
}
#endif
}