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 logs = new List(); private static readonly ConcurrentQueue pendingLogs = new ConcurrentQueue(); 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 ignoredLogKeys = new HashSet(); private GUIStyle pinnedBoxStyle; // Watch mode private static readonly Dictionary watchEntries = new Dictionary(); private static readonly List watchKeyOrder = new List(); // 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(); 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. ...) 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 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(" 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("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 collapseCounts = null; Dictionary collapseHasNew = null; // any member isNew? Dictionary collapseLatestTime = null; // latest timestamp Dictionary collapseLatestFrame = null; // latest frame count HashSet collapseSeenOld = null; HashSet collapseSeenNew = null; if(collapse) { collapseCounts = new Dictionary(); collapseHasNew = new Dictionary(); collapseLatestTime = new Dictionary(); collapseLatestFrame = new Dictionary(); collapseSeenOld = new HashSet(); collapseSeenNew = new HashSet(); 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: , , , , , , , private static readonly Regex RichTextTagRegex = new( @"", 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(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(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(); var other = new List(); 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("
Stack Trace"); sb.AppendLine(); sb.AppendLine("```"); sb.AppendLine(log.stackTrace.TrimEnd()); sb.AppendLine("```"); sb.AppendLine("
"); } 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(); 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 }