@leftium/gg 0.0.43 → 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)
@@ -141,8 +146,9 @@ export function createGgPlugin(options, gg) {
141
146
  // Register the capture hook on gg
142
147
  if (gg) {
143
148
  gg._onLog = (entry) => {
144
- // Track namespaces for filter UI update (check BEFORE pushing to buffer)
145
- 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);
146
152
  buffer.push(entry);
147
153
  // Add new namespace to enabledNamespaces if it matches the current pattern
148
154
  const effectivePattern = filterPattern || 'gg:*';
@@ -150,10 +156,20 @@ export function createGgPlugin(options, gg) {
150
156
  enabledNamespaces.add(entry.namespace);
151
157
  }
152
158
  // Update filter UI if new namespace appeared (updates button summary count)
153
- if (!hadNamespace) {
159
+ if (isNewNamespace) {
154
160
  renderFilterUI();
155
161
  }
156
- 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
+ }
157
173
  };
158
174
  }
159
175
  // Probe for openInEditorPlugin (status 222) and auto-populate $ROOT in dev mode
@@ -187,6 +203,11 @@ export function createGgPlugin(options, gg) {
187
203
  wireUpFilterUI();
188
204
  wireUpSettingsUI();
189
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;
190
211
  renderLogs();
191
212
  },
192
213
  show() {
@@ -205,6 +226,7 @@ export function createGgPlugin(options, gg) {
205
226
  gg._onLog = null;
206
227
  }
207
228
  buffer.clear();
229
+ allNamespacesSet.clear();
208
230
  }
209
231
  };
210
232
  function toggleNamespace(namespace, enable) {
@@ -286,10 +308,7 @@ export function createGgPlugin(options, gg) {
286
308
  return parts.join(',');
287
309
  }
288
310
  function getAllCapturedNamespaces() {
289
- const entries = buffer.getEntries();
290
- const nsSet = new Set();
291
- entries.forEach((e) => nsSet.add(e.namespace));
292
- return Array.from(nsSet).sort();
311
+ return Array.from(allNamespacesSet).sort();
293
312
  }
294
313
  function namespaceMatchesPattern(namespace, pattern) {
295
314
  if (!pattern)
@@ -970,6 +989,7 @@ export function createGgPlugin(options, gg) {
970
989
  </div>
971
990
  <div class="gg-filter-panel"></div>
972
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>
973
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>
974
994
  <div class="gg-toast"></div>
975
995
  <iframe class="gg-editor-iframe" hidden title="open-in-editor"></iframe>
@@ -1372,6 +1392,7 @@ export function createGgPlugin(options, gg) {
1372
1392
  return;
1373
1393
  $el.find('.gg-clear-btn').on('click', () => {
1374
1394
  buffer.clear();
1395
+ allNamespacesSet.clear();
1375
1396
  renderLogs();
1376
1397
  });
1377
1398
  $el.find('.gg-copy-btn').on('click', async () => {
@@ -1971,21 +1992,238 @@ export function createGgPlugin(options, gg) {
1971
1992
  containerEl.addEventListener('pointerup', onPointerUp);
1972
1993
  resizeAttached = true;
1973
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.) */
1974
2215
  function renderLogs() {
1975
2216
  if (!$el)
1976
2217
  return;
1977
2218
  const logContainer = $el.find('.gg-log-container');
1978
- const copyCountSpan = $el.find('.gg-copy-count');
1979
- if (!logContainer.length || !copyCountSpan.length)
2219
+ if (!logContainer.length)
1980
2220
  return;
1981
2221
  const allEntries = buffer.getEntries();
1982
2222
  // Apply filtering
1983
2223
  const entries = allEntries.filter((entry) => enabledNamespaces.has(entry.namespace));
1984
2224
  renderedEntries = entries;
1985
- const countText = entries.length === allEntries.length
1986
- ? `Copy ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`
1987
- : `Copy ${entries.length} / ${allEntries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
1988
- copyCountSpan.html(countText);
2225
+ updateCopyCount();
2226
+ updateTruncationBanner();
1989
2227
  if (entries.length === 0) {
1990
2228
  const hasFilteredLogs = allEntries.length > 0;
1991
2229
  const message = hasFilteredLogs
@@ -1998,139 +2236,7 @@ export function createGgPlugin(options, gg) {
1998
2236
  return;
1999
2237
  }
2000
2238
  const logsHTML = `<div class="gg-log-grid${filterExpanded ? ' filter-mode' : ''}${showExpressions ? ' gg-show-expr' : ''}" style="grid-template-columns: ${gridColumns()};">${entries
2001
- .map((entry, index) => {
2002
- const color = entry.color || '#0066cc';
2003
- const diff = `+${humanize(entry.diff)}`;
2004
- // Split namespace into clickable segments on multiple delimiters: : @ / - _
2005
- const parts = entry.namespace.split(/([:/@ \-_])/);
2006
- const nsSegments = [];
2007
- const delimiters = [];
2008
- for (let i = 0; i < parts.length; i++) {
2009
- if (i % 2 === 0) {
2010
- // Even indices are segments
2011
- if (parts[i])
2012
- nsSegments.push(parts[i]);
2013
- }
2014
- else {
2015
- // Odd indices are delimiters
2016
- delimiters.push(parts[i]);
2017
- }
2018
- }
2019
- let nsHTML = '';
2020
- for (let i = 0; i < nsSegments.length; i++) {
2021
- const segment = escapeHtml(nsSegments[i]);
2022
- // Build filter pattern: reconstruct namespace up to this point
2023
- let filterPattern = '';
2024
- for (let j = 0; j <= i; j++) {
2025
- filterPattern += nsSegments[j];
2026
- if (j < i) {
2027
- filterPattern += delimiters[j];
2028
- }
2029
- else if (j < nsSegments.length - 1) {
2030
- filterPattern += delimiters[j] + '*';
2031
- }
2032
- }
2033
- nsHTML += `<span class="gg-ns-segment" data-filter="${escapeHtml(filterPattern)}">${segment}</span>`;
2034
- if (i < nsSegments.length - 1) {
2035
- nsHTML += escapeHtml(delimiters[i]);
2036
- }
2037
- }
2038
- // Format each arg individually - objects are expandable
2039
- let argsHTML = '';
2040
- let detailsHTML = '';
2041
- // Source expression for this entry (used in hover tooltips and expanded details)
2042
- const srcExpr = entry.src?.trim() && !/^['"`]/.test(entry.src) ? escapeHtml(entry.src) : '';
2043
- // HTML table rendering for gg.table() entries
2044
- if (entry.tableData && entry.tableData.keys.length > 0) {
2045
- const { keys, rows: tableRows } = entry.tableData;
2046
- const headerCells = keys
2047
- .map((k) => `<th style="padding: 2px 8px; border: 1px solid #ccc; background: #f0f0f0; font-size: 11px; white-space: nowrap;">${escapeHtml(k)}</th>`)
2048
- .join('');
2049
- const bodyRowsHtml = tableRows
2050
- .map((row) => {
2051
- const cells = keys
2052
- .map((k) => {
2053
- const val = row[k];
2054
- const display = val === undefined ? '' : escapeHtml(String(val));
2055
- return `<td style="padding: 2px 8px; border: 1px solid #ddd; font-size: 11px; white-space: nowrap;">${display}</td>`;
2056
- })
2057
- .join('');
2058
- return `<tr>${cells}</tr>`;
2059
- })
2060
- .join('');
2061
- 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>`;
2062
- }
2063
- else if (entry.args.length > 0) {
2064
- argsHTML = entry.args
2065
- .map((arg, argIdx) => {
2066
- if (typeof arg === 'object' && arg !== null) {
2067
- // Show expandable object
2068
- const preview = Array.isArray(arg) ? `Array(${arg.length})` : 'Object';
2069
- const jsonStr = escapeHtml(JSON.stringify(arg, null, 2));
2070
- const uniqueId = `${index}-${argIdx}`;
2071
- // Expression header inside expanded details
2072
- const srcHeader = srcExpr ? `<div class="gg-details-src">${srcExpr}</div>` : '';
2073
- // Store details separately to render after the row
2074
- 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>`;
2075
- // data-entry/data-arg for hover tooltip lookup, data-src for expression context
2076
- const srcAttr = srcExpr ? ` data-src="${srcExpr}"` : '';
2077
- const srcIcon = srcExpr ? `<span class="gg-src-icon">\uD83D\uDD0D</span>` : '';
2078
- // Show expression inline after preview when toggle is enabled
2079
- const inlineExpr = showExpressions && srcExpr
2080
- ? ` <span class="gg-inline-expr">\u2039${srcExpr}\u203A</span>`
2081
- : '';
2082
- 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>`;
2083
- }
2084
- else {
2085
- // Parse ANSI codes first, then convert URLs to clickable links
2086
- const argStr = String(arg);
2087
- const parsedAnsi = parseAnsiToHtml(argStr);
2088
- // Note: URL linking happens after ANSI parsing, so links work inside colored text
2089
- // This is a simple approach - URLs inside ANSI codes won't be linkified
2090
- // For more complex parsing, we'd need to track ANSI state while matching URLs
2091
- return `<span>${parsedAnsi}</span>`;
2092
- }
2093
- })
2094
- .join(' ');
2095
- }
2096
- // Time diff will be clickable for open-in-editor when file metadata exists
2097
- // Open-in-editor data attributes (file, line, col)
2098
- const fileAttr = entry.file ? ` data-file="${escapeHtml(entry.file)}"` : '';
2099
- const lineAttr = entry.line ? ` data-line="${entry.line}"` : '';
2100
- const colAttr = entry.col ? ` data-col="${entry.col}"` : '';
2101
- // Level class for info/warn/error styling
2102
- const levelClass = entry.level === 'info'
2103
- ? ' gg-level-info'
2104
- : entry.level === 'warn'
2105
- ? ' gg-level-warn'
2106
- : entry.level === 'error'
2107
- ? ' gg-level-error'
2108
- : '';
2109
- // Stack trace toggle (for error/trace entries with captured stacks)
2110
- let stackHTML = '';
2111
- if (entry.stack) {
2112
- const stackId = `stack-${index}`;
2113
- stackHTML =
2114
- `<span class="gg-stack-toggle" data-stack-id="${stackId}">▶ stack</span>` +
2115
- `<div class="gg-stack-content" data-stack-id="${stackId}">${escapeHtml(entry.stack)}</div>`;
2116
- }
2117
- // Desktop: grid layout, Mobile: stacked layout
2118
- // Expression tooltip: skip table entries (tableData) -- expression is just gg.table(...) which isn't useful
2119
- const hasSrcExpr = !entry.level && !entry.tableData && entry.src?.trim() && !/^['"`]/.test(entry.src);
2120
- // For primitives-only entries, append inline expression when showExpressions is enabled
2121
- const inlineExprForPrimitives = showExpressions && hasSrcExpr && !argsHTML.includes('gg-expand')
2122
- ? ` <span class="gg-inline-expr">\u2039${escapeHtml(entry.src)}\u203A</span>`
2123
- : '';
2124
- return (`<div class="gg-log-entry${levelClass}" data-entry="${index}">` +
2125
- `<div class="gg-log-header">` +
2126
- `<div class="gg-log-diff" style="color: ${color};"${fileAttr}${lineAttr}${colAttr}>${diff}</div>` +
2127
- `<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>` +
2128
- `<div class="gg-log-handle"></div>` +
2129
- `</div>` +
2130
- `<div class="gg-log-content"${hasSrcExpr ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}${inlineExprForPrimitives}${stackHTML}</div>` +
2131
- detailsHTML +
2132
- `</div>`);
2133
- })
2239
+ .map((entry, index) => renderEntryHTML(entry, index))
2134
2240
  .join('')}</div>`;
2135
2241
  logContainer.html(logsHTML);
2136
2242
  // Append hover tooltip div (destroyed by .html() each render, so re-create)
@@ -2158,12 +2264,15 @@ export function createGgPlugin(options, gg) {
2158
2264
  return Math.round(ms / 60000) + 'm ';
2159
2265
  if (abs >= 1000)
2160
2266
  return Math.round(ms / 1000) + 's ';
2161
- return ms + 'ms';
2267
+ return Math.round(ms) + 'ms';
2162
2268
  }
2163
2269
  function escapeHtml(text) {
2164
- const div = document.createElement('div');
2165
- div.textContent = text;
2166
- 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;');
2167
2276
  }
2168
2277
  /**
2169
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.43",
3
+ "version": "0.0.44",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Leftium/gg.git"