forked from Shardstone/trail-into-darkness
2225 lines
105 KiB
C#
2225 lines
105 KiB
C#
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
|
||
}
|