@leftium/gg 0.0.42 → 0.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,9 @@ export function humanize(ms) {
15
15
  return Math.round(ms / 60_000) + 'm';
16
16
  if (abs >= 1_000)
17
17
  return Math.round(ms / 1_000) + 's';
18
- return ms + 'ms';
18
+ if (abs >= 1 || abs === 0)
19
+ return Math.round(ms) + 'ms';
20
+ return ms.toFixed(1) + 'ms';
19
21
  }
20
22
  /**
21
23
  * Wildcard pattern matching (same algorithm as debug's `matchesTemplate`).
@@ -118,7 +120,7 @@ export function setup(env) {
118
120
  const debug = function (...args) {
119
121
  if (!debug.enabled)
120
122
  return;
121
- const curr = Date.now();
123
+ const curr = performance.now();
122
124
  const ms = curr - (prevTime || curr);
123
125
  debug.diff = ms;
124
126
  prevTime = curr;
@@ -5,6 +5,7 @@ import type { CapturedEntry } from './types.js';
5
5
  export declare class LogBuffer {
6
6
  private entries;
7
7
  private maxSize;
8
+ private _totalPushed;
8
9
  constructor(maxSize?: number);
9
10
  /**
10
11
  * Add an entry to the buffer
@@ -23,4 +24,16 @@ export declare class LogBuffer {
23
24
  * Get entry count
24
25
  */
25
26
  get size(): number;
27
+ /**
28
+ * Get total entries ever pushed (including evicted ones)
29
+ */
30
+ get totalPushed(): number;
31
+ /**
32
+ * Get number of entries evicted due to buffer overflow
33
+ */
34
+ get evicted(): number;
35
+ /**
36
+ * Get the maximum capacity
37
+ */
38
+ get capacity(): number;
26
39
  }
@@ -4,6 +4,7 @@
4
4
  export class LogBuffer {
5
5
  entries = [];
6
6
  maxSize;
7
+ _totalPushed = 0;
7
8
  constructor(maxSize = 2000) {
8
9
  this.maxSize = maxSize;
9
10
  }
@@ -12,6 +13,7 @@ export class LogBuffer {
12
13
  * If buffer is full, oldest entry is removed
13
14
  */
14
15
  push(entry) {
16
+ this._totalPushed++;
15
17
  this.entries.push(entry);
16
18
  if (this.entries.length > this.maxSize) {
17
19
  this.entries.shift(); // Remove oldest
@@ -30,6 +32,7 @@ export class LogBuffer {
30
32
  */
31
33
  clear() {
32
34
  this.entries = [];
35
+ this._totalPushed = 0;
33
36
  }
34
37
  /**
35
38
  * Get entry count
@@ -37,4 +40,22 @@ export class LogBuffer {
37
40
  get size() {
38
41
  return this.entries.length;
39
42
  }
43
+ /**
44
+ * Get total entries ever pushed (including evicted ones)
45
+ */
46
+ get totalPushed() {
47
+ return this._totalPushed;
48
+ }
49
+ /**
50
+ * Get number of entries evicted due to buffer overflow
51
+ */
52
+ get evicted() {
53
+ return this._totalPushed - this.entries.length;
54
+ }
55
+ /**
56
+ * Get the maximum capacity
57
+ */
58
+ get capacity() {
59
+ return this.maxSize;
60
+ }
40
61
  }
@@ -88,6 +88,8 @@ export async function loadEruda(options) {
88
88
  eruda.add(ggPlugin);
89
89
  // Make GG tab the default selected tab
90
90
  eruda.show('GG');
91
+ // Expand the Eruda panel (open from minimized floating button state)
92
+ eruda.show();
91
93
  // Run diagnostics after Eruda is ready so they appear in Console tab
92
94
  await runGgDiagnostics();
93
95
  }
@@ -37,6 +37,11 @@ export function createGgPlugin(options, gg) {
37
37
  const COPY_FORMAT_KEY = 'gg-copy-format';
38
38
  const URL_FORMAT_KEY = 'gg-url-format';
39
39
  const PROJECT_ROOT_KEY = 'gg-project-root';
40
+ // Render batching: coalesce multiple _onLog calls into a single rAF
41
+ let renderPending = false;
42
+ let pendingEntries = []; // new entries since last render
43
+ // All namespaces ever seen (maintained incrementally, avoids scanning buffer)
44
+ const allNamespacesSet = new Set();
40
45
  // Plugin detection state (probed once at init)
41
46
  let openInEditorPluginDetected = null; // null = not yet probed
42
47
  // Editor bins for launch-editor (common first, then alphabetical)
@@ -106,6 +111,30 @@ export function createGgPlugin(options, gg) {
106
111
  localStorage.setItem(COPY_FORMAT_KEY, copyFormat);
107
112
  }
108
113
  }
114
+ /**
115
+ * Format a single log entry for clipboard copy
116
+ * Produces: HH:MM:SS.mmm namespace args [optional expression]
117
+ */
118
+ function formatEntryForClipboard(entry, includeExpressions) {
119
+ // Extract HH:MM:SS.mmm from timestamp (with milliseconds)
120
+ const time = new Date(entry.timestamp).toISOString().slice(11, 23);
121
+ // Trim namespace and strip 'gg:' prefix to save tokens
122
+ const ns = entry.namespace.trim().replace(/^gg:/, '');
123
+ // Include expression suffix when toggle is enabled
124
+ const hasSrcExpr = !entry.level && entry.src?.trim() && !/^['"`]/.test(entry.src);
125
+ const exprSuffix = includeExpressions && hasSrcExpr ? ` \u2039${entry.src}\u203A` : '';
126
+ // Format args: compact JSON for objects, primitives as-is
127
+ const argsStr = entry.args
128
+ .map((arg) => {
129
+ if (typeof arg === 'object' && arg !== null) {
130
+ return JSON.stringify(arg);
131
+ }
132
+ // Strip ANSI escape codes from string args
133
+ return stripAnsi(String(arg));
134
+ })
135
+ .join(' ');
136
+ return `${time} ${ns} ${argsStr}${exprSuffix}`;
137
+ }
109
138
  const plugin = {
110
139
  name: 'GG',
111
140
  init($container) {
@@ -117,8 +146,9 @@ export function createGgPlugin(options, gg) {
117
146
  // Register the capture hook on gg
118
147
  if (gg) {
119
148
  gg._onLog = (entry) => {
120
- // Track namespaces for filter UI update (check BEFORE pushing to buffer)
121
- const hadNamespace = getAllCapturedNamespaces().includes(entry.namespace);
149
+ // Track namespaces incrementally (O(1) instead of scanning buffer)
150
+ const isNewNamespace = !allNamespacesSet.has(entry.namespace);
151
+ allNamespacesSet.add(entry.namespace);
122
152
  buffer.push(entry);
123
153
  // Add new namespace to enabledNamespaces if it matches the current pattern
124
154
  const effectivePattern = filterPattern || 'gg:*';
@@ -126,10 +156,20 @@ export function createGgPlugin(options, gg) {
126
156
  enabledNamespaces.add(entry.namespace);
127
157
  }
128
158
  // Update filter UI if new namespace appeared (updates button summary count)
129
- if (!hadNamespace) {
159
+ if (isNewNamespace) {
130
160
  renderFilterUI();
131
161
  }
132
- renderLogs();
162
+ // Batch: collect pending entries, schedule one render per frame
163
+ pendingEntries.push(entry);
164
+ if (!renderPending) {
165
+ renderPending = true;
166
+ requestAnimationFrame(() => {
167
+ renderPending = false;
168
+ const batch = pendingEntries;
169
+ pendingEntries = [];
170
+ appendLogs(batch);
171
+ });
172
+ }
133
173
  };
134
174
  }
135
175
  // Probe for openInEditorPlugin (status 222) and auto-populate $ROOT in dev mode
@@ -163,6 +203,11 @@ export function createGgPlugin(options, gg) {
163
203
  wireUpFilterUI();
164
204
  wireUpSettingsUI();
165
205
  wireUpToast();
206
+ // Discard any entries queued during early-buffer replay (before the DOM
207
+ // existed). renderLogs() below will do a full render from buffer, so the
208
+ // pending rAF batch would only duplicate those entries.
209
+ pendingEntries = [];
210
+ renderPending = false;
166
211
  renderLogs();
167
212
  },
168
213
  show() {
@@ -181,6 +226,7 @@ export function createGgPlugin(options, gg) {
181
226
  gg._onLog = null;
182
227
  }
183
228
  buffer.clear();
229
+ allNamespacesSet.clear();
184
230
  }
185
231
  };
186
232
  function toggleNamespace(namespace, enable) {
@@ -262,10 +308,7 @@ export function createGgPlugin(options, gg) {
262
308
  return parts.join(',');
263
309
  }
264
310
  function getAllCapturedNamespaces() {
265
- const entries = buffer.getEntries();
266
- const nsSet = new Set();
267
- entries.forEach((e) => nsSet.add(e.namespace));
268
- return Array.from(nsSet).sort();
311
+ return Array.from(allNamespacesSet).sort();
269
312
  }
270
313
  function namespaceMatchesPattern(namespace, pattern) {
271
314
  if (!pattern)
@@ -946,6 +989,7 @@ export function createGgPlugin(options, gg) {
946
989
  </div>
947
990
  <div class="gg-filter-panel"></div>
948
991
  <div class="gg-settings-panel"></div>
992
+ <div class="gg-truncation-banner" style="display: none; padding: 6px 12px; background: #7f4f00; color: #ffe0a0; font-size: 11px; align-items: center; gap: 6px; flex-shrink: 0;"></div>
949
993
  <div class="gg-log-container" style="flex: 1; overflow-y: auto; overflow-x: hidden; font-family: monospace; font-size: 12px; touch-action: pan-y; overscroll-behavior: contain;"></div>
950
994
  <div class="gg-toast"></div>
951
995
  <iframe class="gg-editor-iframe" hidden title="open-in-editor"></iframe>
@@ -1348,6 +1392,7 @@ export function createGgPlugin(options, gg) {
1348
1392
  return;
1349
1393
  $el.find('.gg-clear-btn').on('click', () => {
1350
1394
  buffer.clear();
1395
+ allNamespacesSet.clear();
1351
1396
  renderLogs();
1352
1397
  });
1353
1398
  $el.find('.gg-copy-btn').on('click', async () => {
@@ -1355,26 +1400,7 @@ export function createGgPlugin(options, gg) {
1355
1400
  // Apply same filtering as renderLogs() - only copy visible entries
1356
1401
  const entries = allEntries.filter((entry) => enabledNamespaces.has(entry.namespace));
1357
1402
  const text = entries
1358
- .map((e) => {
1359
- // Extract just HH:MM:SS from timestamp (compact for LLMs)
1360
- const time = new Date(e.timestamp).toISOString().slice(11, 19);
1361
- // Trim namespace and strip 'gg:' prefix to save tokens
1362
- const ns = e.namespace.trim().replace(/^gg:/, '');
1363
- // Include expression suffix when toggle is enabled
1364
- const hasSrcExpr = !e.level && e.src?.trim() && !/^['"`]/.test(e.src);
1365
- const exprSuffix = showExpressions && hasSrcExpr ? ` \u2039${e.src}\u203A` : '';
1366
- // Format args: compact JSON for objects, primitives as-is
1367
- const argsStr = e.args
1368
- .map((arg) => {
1369
- if (typeof arg === 'object' && arg !== null) {
1370
- return JSON.stringify(arg);
1371
- }
1372
- // Strip ANSI escape codes from string args
1373
- return stripAnsi(String(arg));
1374
- })
1375
- .join(' ');
1376
- return `${time} ${ns} ${argsStr}${exprSuffix}`;
1377
- })
1403
+ .map((e) => formatEntryForClipboard(e, showExpressions))
1378
1404
  .join('\n');
1379
1405
  try {
1380
1406
  await navigator.clipboard.writeText(text);
@@ -1795,20 +1821,7 @@ export function createGgPlugin(options, gg) {
1795
1821
  if (!entry)
1796
1822
  return;
1797
1823
  e.preventDefault();
1798
- const time = new Date(entry.timestamp).toISOString().slice(11, 19);
1799
- const ns = entry.namespace.trim().replace(/^gg:/, '');
1800
- // Include expression suffix when toggle is enabled
1801
- const hasSrcExpr = !entry.level && entry.src?.trim() && !/^['"`]/.test(entry.src);
1802
- const exprSuffix = showExpressions && hasSrcExpr ? ` \u2039${entry.src}\u203A` : '';
1803
- const argsStr = entry.args
1804
- .map((arg) => {
1805
- if (typeof arg === 'object' && arg !== null) {
1806
- return JSON.stringify(arg);
1807
- }
1808
- return stripAnsi(String(arg));
1809
- })
1810
- .join(' ');
1811
- const text = `${time} ${ns} ${argsStr}${exprSuffix}`;
1824
+ const text = formatEntryForClipboard(entry, showExpressions);
1812
1825
  navigator.clipboard.writeText(text).then(() => {
1813
1826
  showConfirmationTooltip(containerEl, contentEl, 'Copied message');
1814
1827
  });
@@ -1979,21 +1992,238 @@ export function createGgPlugin(options, gg) {
1979
1992
  containerEl.addEventListener('pointerup', onPointerUp);
1980
1993
  resizeAttached = true;
1981
1994
  }
1995
+ /** Build the HTML string for a single log entry */
1996
+ function renderEntryHTML(entry, index) {
1997
+ const color = entry.color || '#0066cc';
1998
+ const diff = `+${humanize(entry.diff)}`;
1999
+ // Split namespace into clickable segments on multiple delimiters: : @ / - _
2000
+ const parts = entry.namespace.split(/([:/@ \-_])/);
2001
+ const nsSegments = [];
2002
+ const delimiters = [];
2003
+ for (let i = 0; i < parts.length; i++) {
2004
+ if (i % 2 === 0) {
2005
+ // Even indices are segments
2006
+ if (parts[i])
2007
+ nsSegments.push(parts[i]);
2008
+ }
2009
+ else {
2010
+ // Odd indices are delimiters
2011
+ delimiters.push(parts[i]);
2012
+ }
2013
+ }
2014
+ let nsHTML = '';
2015
+ for (let i = 0; i < nsSegments.length; i++) {
2016
+ const segment = escapeHtml(nsSegments[i]);
2017
+ // Build filter pattern: reconstruct namespace up to this point
2018
+ let filterPattern = '';
2019
+ for (let j = 0; j <= i; j++) {
2020
+ filterPattern += nsSegments[j];
2021
+ if (j < i) {
2022
+ filterPattern += delimiters[j];
2023
+ }
2024
+ else if (j < nsSegments.length - 1) {
2025
+ filterPattern += delimiters[j] + '*';
2026
+ }
2027
+ }
2028
+ nsHTML += `<span class="gg-ns-segment" data-filter="${escapeHtml(filterPattern)}">${segment}</span>`;
2029
+ if (i < nsSegments.length - 1) {
2030
+ nsHTML += escapeHtml(delimiters[i]);
2031
+ }
2032
+ }
2033
+ // Format each arg individually - objects are expandable
2034
+ let argsHTML = '';
2035
+ let detailsHTML = '';
2036
+ // Source expression for this entry (used in hover tooltips and expanded details)
2037
+ const srcExpr = entry.src?.trim() && !/^['"`]/.test(entry.src) ? escapeHtml(entry.src) : '';
2038
+ // HTML table rendering for gg.table() entries
2039
+ if (entry.tableData && entry.tableData.keys.length > 0) {
2040
+ const { keys, rows: tableRows } = entry.tableData;
2041
+ const headerCells = keys
2042
+ .map((k) => `<th style="padding: 2px 8px; border: 1px solid #ccc; background: #f0f0f0; font-size: 11px; white-space: nowrap;">${escapeHtml(k)}</th>`)
2043
+ .join('');
2044
+ const bodyRowsHtml = tableRows
2045
+ .map((row) => {
2046
+ const cells = keys
2047
+ .map((k) => {
2048
+ const val = row[k];
2049
+ const display = val === undefined ? '' : escapeHtml(String(val));
2050
+ return `<td style="padding: 2px 8px; border: 1px solid #ddd; font-size: 11px; white-space: nowrap;">${display}</td>`;
2051
+ })
2052
+ .join('');
2053
+ return `<tr>${cells}</tr>`;
2054
+ })
2055
+ .join('');
2056
+ argsHTML = `<div style="overflow-x: auto;"><table style="border-collapse: collapse; margin: 2px 0; font-family: monospace;"><thead><tr>${headerCells}</tr></thead><tbody>${bodyRowsHtml}</tbody></table></div>`;
2057
+ }
2058
+ else if (entry.args.length > 0) {
2059
+ argsHTML = entry.args
2060
+ .map((arg, argIdx) => {
2061
+ if (typeof arg === 'object' && arg !== null) {
2062
+ // Show expandable object
2063
+ const preview = Array.isArray(arg) ? `Array(${arg.length})` : 'Object';
2064
+ const jsonStr = escapeHtml(JSON.stringify(arg, null, 2));
2065
+ const uniqueId = `${index}-${argIdx}`;
2066
+ // Expression header inside expanded details
2067
+ const srcHeader = srcExpr ? `<div class="gg-details-src">${srcExpr}</div>` : '';
2068
+ // Store details separately to render after the row
2069
+ detailsHTML += `<div class="gg-details" data-index="${uniqueId}" style="display: none; margin: 4px 0 8px 0; padding: 8px; background: #f8f8f8; border-left: 3px solid ${color}; font-size: 11px; overflow-x: auto;">${srcHeader}<pre style="margin: 0;">${jsonStr}</pre></div>`;
2070
+ // data-entry/data-arg for hover tooltip lookup, data-src for expression context
2071
+ const srcAttr = srcExpr ? ` data-src="${srcExpr}"` : '';
2072
+ const srcIcon = srcExpr ? `<span class="gg-src-icon">\uD83D\uDD0D</span>` : '';
2073
+ // Show expression inline after preview when toggle is enabled
2074
+ const inlineExpr = showExpressions && srcExpr
2075
+ ? ` <span class="gg-inline-expr">\u2039${srcExpr}\u203A</span>`
2076
+ : '';
2077
+ return `<span style="color: #888; cursor: pointer; text-decoration: underline;" class="gg-expand" data-index="${uniqueId}" data-entry="${index}" data-arg="${argIdx}"${srcAttr}>${srcIcon}${preview}${inlineExpr}</span>`;
2078
+ }
2079
+ else {
2080
+ // Parse ANSI codes first, then convert URLs to clickable links
2081
+ const argStr = String(arg);
2082
+ const parsedAnsi = parseAnsiToHtml(argStr);
2083
+ return `<span>${parsedAnsi}</span>`;
2084
+ }
2085
+ })
2086
+ .join(' ');
2087
+ }
2088
+ // Open-in-editor data attributes (file, line, col)
2089
+ const fileAttr = entry.file ? ` data-file="${escapeHtml(entry.file)}"` : '';
2090
+ const lineAttr = entry.line ? ` data-line="${entry.line}"` : '';
2091
+ const colAttr = entry.col ? ` data-col="${entry.col}"` : '';
2092
+ // Level class for info/warn/error styling
2093
+ const levelClass = entry.level === 'info'
2094
+ ? ' gg-level-info'
2095
+ : entry.level === 'warn'
2096
+ ? ' gg-level-warn'
2097
+ : entry.level === 'error'
2098
+ ? ' gg-level-error'
2099
+ : '';
2100
+ // Stack trace toggle (for error/trace entries with captured stacks)
2101
+ let stackHTML = '';
2102
+ if (entry.stack) {
2103
+ const stackId = `stack-${index}`;
2104
+ stackHTML =
2105
+ `<span class="gg-stack-toggle" data-stack-id="${stackId}">▶ stack</span>` +
2106
+ `<div class="gg-stack-content" data-stack-id="${stackId}">${escapeHtml(entry.stack)}</div>`;
2107
+ }
2108
+ // Expression tooltip: skip table entries (tableData) -- expression is just gg.table(...) which isn't useful
2109
+ const hasSrcExpr = !entry.level && !entry.tableData && entry.src?.trim() && !/^['"`]/.test(entry.src);
2110
+ // For primitives-only entries, append inline expression when showExpressions is enabled
2111
+ const inlineExprForPrimitives = showExpressions && hasSrcExpr && !argsHTML.includes('gg-expand')
2112
+ ? ` <span class="gg-inline-expr">\u2039${escapeHtml(entry.src)}\u203A</span>`
2113
+ : '';
2114
+ return (`<div class="gg-log-entry${levelClass}" data-entry="${index}">` +
2115
+ `<div class="gg-log-header">` +
2116
+ `<div class="gg-log-diff" style="color: ${color};"${fileAttr}${lineAttr}${colAttr}>${diff}</div>` +
2117
+ `<div class="gg-log-ns" style="color: ${color};" data-namespace="${escapeHtml(entry.namespace)}"><span class="gg-ns-text">${nsHTML}</span><button class="gg-ns-hide" data-namespace="${escapeHtml(entry.namespace)}" title="Hide this namespace">\u00d7</button></div>` +
2118
+ `<div class="gg-log-handle"></div>` +
2119
+ `</div>` +
2120
+ `<div class="gg-log-content"${hasSrcExpr ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}${inlineExprForPrimitives}${stackHTML}</div>` +
2121
+ detailsHTML +
2122
+ `</div>`);
2123
+ }
2124
+ /** Update the copy-button count text */
2125
+ function updateTruncationBanner() {
2126
+ if (!$el)
2127
+ return;
2128
+ const banner = $el.find('.gg-truncation-banner').get(0);
2129
+ if (!banner)
2130
+ return;
2131
+ const evicted = buffer.evicted;
2132
+ if (evicted > 0) {
2133
+ const total = buffer.totalPushed;
2134
+ const retained = buffer.size;
2135
+ banner.innerHTML = `⚠ Showing ${retained.toLocaleString()} of ${total.toLocaleString()} messages &mdash; ${evicted.toLocaleString()} truncated. Increase <code style="font-family:monospace;background:rgba(255,255,255,0.15);padding:0 3px;border-radius:3px;">maxEntries</code> to retain more.`;
2136
+ banner.style.display = 'flex';
2137
+ }
2138
+ else {
2139
+ banner.style.display = 'none';
2140
+ }
2141
+ }
2142
+ function updateCopyCount() {
2143
+ if (!$el)
2144
+ return;
2145
+ const copyCountSpan = $el.find('.gg-copy-count');
2146
+ if (!copyCountSpan.length)
2147
+ return;
2148
+ const allCount = buffer.size;
2149
+ const visibleCount = renderedEntries.length;
2150
+ const countText = visibleCount === allCount
2151
+ ? `Copy ${visibleCount} ${visibleCount === 1 ? 'entry' : 'entries'}`
2152
+ : `Copy ${visibleCount} / ${allCount} ${visibleCount === 1 ? 'entry' : 'entries'}`;
2153
+ copyCountSpan.html(countText);
2154
+ }
2155
+ /** Scroll to bottom only if user is already near the bottom */
2156
+ function autoScroll(el) {
2157
+ const threshold = 50; // px from bottom
2158
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
2159
+ if (nearBottom)
2160
+ el.scrollTop = el.scrollHeight;
2161
+ }
2162
+ /**
2163
+ * Incremental append: add new entries to the existing DOM without full rebuild.
2164
+ * Called from the rAF-batched _onLog path.
2165
+ */
2166
+ function appendLogs(newEntries) {
2167
+ if (!$el)
2168
+ return;
2169
+ const logContainer = $el.find('.gg-log-container');
2170
+ if (!logContainer.length)
2171
+ return;
2172
+ // Filter new entries to only those matching enabled namespaces
2173
+ const visibleNew = newEntries.filter((e) => enabledNamespaces.has(e.namespace));
2174
+ // If there's no grid yet (first entries, or was showing empty state), do a full render
2175
+ const containerDom = logContainer.get(0);
2176
+ const grid = containerDom?.querySelector('.gg-log-grid');
2177
+ if (!grid || !containerDom) {
2178
+ renderLogs();
2179
+ return;
2180
+ }
2181
+ if (visibleNew.length === 0) {
2182
+ // New entries were all filtered out, just update count
2183
+ updateCopyCount();
2184
+ return;
2185
+ }
2186
+ // Compute the starting index (position in the renderedEntries array)
2187
+ const startIndex = renderedEntries.length;
2188
+ // Add to renderedEntries
2189
+ renderedEntries.push(...visibleNew);
2190
+ // Build HTML for just the new entries
2191
+ const html = visibleNew.map((entry, i) => renderEntryHTML(entry, startIndex + i)).join('');
2192
+ // Append to existing grid (no full DOM teardown!)
2193
+ grid.insertAdjacentHTML('beforeend', html);
2194
+ // Handle buffer overflow: the buffer evicts oldest entries when full.
2195
+ // Count DOM entries and remove excess from the front to stay in sync.
2196
+ // This avoids an expensive buffer.getEntries() + filter call.
2197
+ const domEntries = grid.querySelectorAll(':scope > .gg-log-entry');
2198
+ const excess = domEntries.length - buffer.capacity;
2199
+ if (excess > 0) {
2200
+ for (let i = 0; i < excess; i++) {
2201
+ // Remove the entry and any associated .gg-details siblings
2202
+ domEntries[i].remove();
2203
+ }
2204
+ // Trim renderedEntries from the front to match
2205
+ renderedEntries.splice(0, excess);
2206
+ }
2207
+ updateCopyCount();
2208
+ updateTruncationBanner();
2209
+ // Re-wire expanders after rendering
2210
+ wireUpExpanders();
2211
+ // Smart auto-scroll
2212
+ autoScroll(containerDom);
2213
+ }
2214
+ /** Full render: rebuild the entire log view (used for filter changes, clear, show, etc.) */
1982
2215
  function renderLogs() {
1983
2216
  if (!$el)
1984
2217
  return;
1985
2218
  const logContainer = $el.find('.gg-log-container');
1986
- const copyCountSpan = $el.find('.gg-copy-count');
1987
- if (!logContainer.length || !copyCountSpan.length)
2219
+ if (!logContainer.length)
1988
2220
  return;
1989
2221
  const allEntries = buffer.getEntries();
1990
2222
  // Apply filtering
1991
2223
  const entries = allEntries.filter((entry) => enabledNamespaces.has(entry.namespace));
1992
2224
  renderedEntries = entries;
1993
- const countText = entries.length === allEntries.length
1994
- ? `Copy ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`
1995
- : `Copy ${entries.length} / ${allEntries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
1996
- copyCountSpan.html(countText);
2225
+ updateCopyCount();
2226
+ updateTruncationBanner();
1997
2227
  if (entries.length === 0) {
1998
2228
  const hasFilteredLogs = allEntries.length > 0;
1999
2229
  const message = hasFilteredLogs
@@ -2006,139 +2236,7 @@ export function createGgPlugin(options, gg) {
2006
2236
  return;
2007
2237
  }
2008
2238
  const logsHTML = `<div class="gg-log-grid${filterExpanded ? ' filter-mode' : ''}${showExpressions ? ' gg-show-expr' : ''}" style="grid-template-columns: ${gridColumns()};">${entries
2009
- .map((entry, index) => {
2010
- const color = entry.color || '#0066cc';
2011
- const diff = `+${humanize(entry.diff)}`;
2012
- // Split namespace into clickable segments on multiple delimiters: : @ / - _
2013
- const parts = entry.namespace.split(/([:/@ \-_])/);
2014
- const nsSegments = [];
2015
- const delimiters = [];
2016
- for (let i = 0; i < parts.length; i++) {
2017
- if (i % 2 === 0) {
2018
- // Even indices are segments
2019
- if (parts[i])
2020
- nsSegments.push(parts[i]);
2021
- }
2022
- else {
2023
- // Odd indices are delimiters
2024
- delimiters.push(parts[i]);
2025
- }
2026
- }
2027
- let nsHTML = '';
2028
- for (let i = 0; i < nsSegments.length; i++) {
2029
- const segment = escapeHtml(nsSegments[i]);
2030
- // Build filter pattern: reconstruct namespace up to this point
2031
- let filterPattern = '';
2032
- for (let j = 0; j <= i; j++) {
2033
- filterPattern += nsSegments[j];
2034
- if (j < i) {
2035
- filterPattern += delimiters[j];
2036
- }
2037
- else if (j < nsSegments.length - 1) {
2038
- filterPattern += delimiters[j] + '*';
2039
- }
2040
- }
2041
- nsHTML += `<span class="gg-ns-segment" data-filter="${escapeHtml(filterPattern)}">${segment}</span>`;
2042
- if (i < nsSegments.length - 1) {
2043
- nsHTML += escapeHtml(delimiters[i]);
2044
- }
2045
- }
2046
- // Format each arg individually - objects are expandable
2047
- let argsHTML = '';
2048
- let detailsHTML = '';
2049
- // Source expression for this entry (used in hover tooltips and expanded details)
2050
- const srcExpr = entry.src?.trim() && !/^['"`]/.test(entry.src) ? escapeHtml(entry.src) : '';
2051
- // HTML table rendering for gg.table() entries
2052
- if (entry.tableData && entry.tableData.keys.length > 0) {
2053
- const { keys, rows: tableRows } = entry.tableData;
2054
- const headerCells = keys
2055
- .map((k) => `<th style="padding: 2px 8px; border: 1px solid #ccc; background: #f0f0f0; font-size: 11px; white-space: nowrap;">${escapeHtml(k)}</th>`)
2056
- .join('');
2057
- const bodyRowsHtml = tableRows
2058
- .map((row) => {
2059
- const cells = keys
2060
- .map((k) => {
2061
- const val = row[k];
2062
- const display = val === undefined ? '' : escapeHtml(String(val));
2063
- return `<td style="padding: 2px 8px; border: 1px solid #ddd; font-size: 11px; white-space: nowrap;">${display}</td>`;
2064
- })
2065
- .join('');
2066
- return `<tr>${cells}</tr>`;
2067
- })
2068
- .join('');
2069
- argsHTML = `<div style="overflow-x: auto;"><table style="border-collapse: collapse; margin: 2px 0; font-family: monospace;"><thead><tr>${headerCells}</tr></thead><tbody>${bodyRowsHtml}</tbody></table></div>`;
2070
- }
2071
- else if (entry.args.length > 0) {
2072
- argsHTML = entry.args
2073
- .map((arg, argIdx) => {
2074
- if (typeof arg === 'object' && arg !== null) {
2075
- // Show expandable object
2076
- const preview = Array.isArray(arg) ? `Array(${arg.length})` : 'Object';
2077
- const jsonStr = escapeHtml(JSON.stringify(arg, null, 2));
2078
- const uniqueId = `${index}-${argIdx}`;
2079
- // Expression header inside expanded details
2080
- const srcHeader = srcExpr ? `<div class="gg-details-src">${srcExpr}</div>` : '';
2081
- // Store details separately to render after the row
2082
- detailsHTML += `<div class="gg-details" data-index="${uniqueId}" style="display: none; margin: 4px 0 8px 0; padding: 8px; background: #f8f8f8; border-left: 3px solid ${color}; font-size: 11px; overflow-x: auto;">${srcHeader}<pre style="margin: 0;">${jsonStr}</pre></div>`;
2083
- // data-entry/data-arg for hover tooltip lookup, data-src for expression context
2084
- const srcAttr = srcExpr ? ` data-src="${srcExpr}"` : '';
2085
- const srcIcon = srcExpr ? `<span class="gg-src-icon">\uD83D\uDD0D</span>` : '';
2086
- // Show expression inline after preview when toggle is enabled
2087
- const inlineExpr = showExpressions && srcExpr
2088
- ? ` <span class="gg-inline-expr">\u2039${srcExpr}\u203A</span>`
2089
- : '';
2090
- return `<span style="color: #888; cursor: pointer; text-decoration: underline;" class="gg-expand" data-index="${uniqueId}" data-entry="${index}" data-arg="${argIdx}"${srcAttr}>${srcIcon}${preview}${inlineExpr}</span>`;
2091
- }
2092
- else {
2093
- // Parse ANSI codes first, then convert URLs to clickable links
2094
- const argStr = String(arg);
2095
- const parsedAnsi = parseAnsiToHtml(argStr);
2096
- // Note: URL linking happens after ANSI parsing, so links work inside colored text
2097
- // This is a simple approach - URLs inside ANSI codes won't be linkified
2098
- // For more complex parsing, we'd need to track ANSI state while matching URLs
2099
- return `<span>${parsedAnsi}</span>`;
2100
- }
2101
- })
2102
- .join(' ');
2103
- }
2104
- // Time diff will be clickable for open-in-editor when file metadata exists
2105
- // Open-in-editor data attributes (file, line, col)
2106
- const fileAttr = entry.file ? ` data-file="${escapeHtml(entry.file)}"` : '';
2107
- const lineAttr = entry.line ? ` data-line="${entry.line}"` : '';
2108
- const colAttr = entry.col ? ` data-col="${entry.col}"` : '';
2109
- // Level class for info/warn/error styling
2110
- const levelClass = entry.level === 'info'
2111
- ? ' gg-level-info'
2112
- : entry.level === 'warn'
2113
- ? ' gg-level-warn'
2114
- : entry.level === 'error'
2115
- ? ' gg-level-error'
2116
- : '';
2117
- // Stack trace toggle (for error/trace entries with captured stacks)
2118
- let stackHTML = '';
2119
- if (entry.stack) {
2120
- const stackId = `stack-${index}`;
2121
- stackHTML =
2122
- `<span class="gg-stack-toggle" data-stack-id="${stackId}">▶ stack</span>` +
2123
- `<div class="gg-stack-content" data-stack-id="${stackId}">${escapeHtml(entry.stack)}</div>`;
2124
- }
2125
- // Desktop: grid layout, Mobile: stacked layout
2126
- // Expression tooltip: skip table entries (tableData) -- expression is just gg.table(...) which isn't useful
2127
- const hasSrcExpr = !entry.level && !entry.tableData && entry.src?.trim() && !/^['"`]/.test(entry.src);
2128
- // For primitives-only entries, append inline expression when showExpressions is enabled
2129
- const inlineExprForPrimitives = showExpressions && hasSrcExpr && !argsHTML.includes('gg-expand')
2130
- ? ` <span class="gg-inline-expr">\u2039${escapeHtml(entry.src)}\u203A</span>`
2131
- : '';
2132
- return (`<div class="gg-log-entry${levelClass}" data-entry="${index}">` +
2133
- `<div class="gg-log-header">` +
2134
- `<div class="gg-log-diff" style="color: ${color};"${fileAttr}${lineAttr}${colAttr}>${diff}</div>` +
2135
- `<div class="gg-log-ns" style="color: ${color};" data-namespace="${escapeHtml(entry.namespace)}"><span class="gg-ns-text">${nsHTML}</span><button class="gg-ns-hide" data-namespace="${escapeHtml(entry.namespace)}" title="Hide this namespace">\u00d7</button></div>` +
2136
- `<div class="gg-log-handle"></div>` +
2137
- `</div>` +
2138
- `<div class="gg-log-content"${hasSrcExpr ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}${inlineExprForPrimitives}${stackHTML}</div>` +
2139
- detailsHTML +
2140
- `</div>`);
2141
- })
2239
+ .map((entry, index) => renderEntryHTML(entry, index))
2142
2240
  .join('')}</div>`;
2143
2241
  logContainer.html(logsHTML);
2144
2242
  // Append hover tooltip div (destroyed by .html() each render, so re-create)
@@ -2166,12 +2264,15 @@ export function createGgPlugin(options, gg) {
2166
2264
  return Math.round(ms / 60000) + 'm ';
2167
2265
  if (abs >= 1000)
2168
2266
  return Math.round(ms / 1000) + 's ';
2169
- return ms + 'ms';
2267
+ return Math.round(ms) + 'ms';
2170
2268
  }
2171
2269
  function escapeHtml(text) {
2172
- const div = document.createElement('div');
2173
- div.textContent = text;
2174
- return div.innerHTML;
2270
+ return text
2271
+ .replace(/&/g, '&amp;')
2272
+ .replace(/</g, '&lt;')
2273
+ .replace(/>/g, '&gt;')
2274
+ .replace(/"/g, '&quot;')
2275
+ .replace(/'/g, '&#39;');
2175
2276
  }
2176
2277
  /**
2177
2278
  * Strip ANSI escape codes from text
package/dist/gg.js CHANGED
@@ -182,6 +182,9 @@ const srcRootRegex = new RegExp(ggConfig.srcRootPattern, 'i');
182
182
  // - Cache and reuse the same log function for a given callpoint.
183
183
  const namespaceToLogFunction = new Map();
184
184
  let maxCallpointLength = 0;
185
+ // Per-namespace prevTime for diff tracking (independent of debug library's enabled state,
186
+ // so GgConsole diffs are correct even when localStorage.debug doesn't include gg:*).
187
+ const namespaceToPrevTime = new Map();
185
188
  // Cache: raw stack line → word tuple (avoids re-hashing the same call site)
186
189
  const stackLineCache = new Map();
187
190
  /**
@@ -311,6 +314,14 @@ function ggLog(options, ...args) {
311
314
  else if (level === 'error') {
312
315
  logArgs[0] = `⛔ ${logArgs[0]}`;
313
316
  }
317
+ // Compute diff independently of the debug library's enabled state.
318
+ // ggLogFunction.diff only updates when the debugger is enabled (i.e. localStorage.debug
319
+ // matches the namespace), so relying on it would always show +0ms when console output is
320
+ // disabled — even though the GgConsole panel always captures entries.
321
+ const now = performance.now();
322
+ const prevTime = namespaceToPrevTime.get(namespace);
323
+ const diff = prevTime !== undefined ? now - prevTime : 0;
324
+ namespaceToPrevTime.set(namespace, now);
314
325
  // Log to console via debug
315
326
  if (logArgs.length === 1) {
316
327
  ggLogFunction(logArgs[0]);
@@ -322,7 +333,7 @@ function ggLog(options, ...args) {
322
333
  const entry = {
323
334
  namespace,
324
335
  color: ggLogFunction.color,
325
- diff: ggLogFunction.diff || 0,
336
+ diff,
326
337
  message: logArgs.length === 1 ? String(logArgs[0]) : logArgs.map(String).join(' '),
327
338
  args: logArgs,
328
339
  timestamp: Date.now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftium/gg",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Leftium/gg.git"