@leftium/gg 0.0.44 → 0.0.46

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.
@@ -2,11 +2,11 @@
2
2
  import { onMount } from 'svelte';
3
3
  import type { GgErudaOptions } from './eruda/types.js';
4
4
 
5
- let { prod, maxEntries, erudaOptions }: GgErudaOptions = $props();
5
+ let { prod, maxEntries, erudaOptions, open }: GgErudaOptions = $props();
6
6
 
7
7
  onMount(() => {
8
8
  import('./eruda/index.js').then(({ initGgEruda }) => {
9
- initGgEruda({ prod, maxEntries, erudaOptions });
9
+ initGgEruda({ prod, maxEntries, erudaOptions, open });
10
10
  });
11
11
  });
12
12
  </script>
@@ -1,19 +1,36 @@
1
1
  import type { CapturedEntry } from './types.js';
2
2
  /**
3
- * Ring buffer for captured log entries
3
+ * Ring buffer for captured log entries.
4
+ *
5
+ * Uses a fixed-size array with a head pointer so that push() is always O(1).
6
+ * The previous implementation used Array.push + Array.shift which is O(n)
7
+ * once the buffer is full (every shift copies all elements forward).
4
8
  */
5
9
  export declare class LogBuffer {
6
- private entries;
10
+ private buf;
11
+ private head;
12
+ private count;
7
13
  private maxSize;
8
14
  private _totalPushed;
9
15
  constructor(maxSize?: number);
10
16
  /**
11
- * Add an entry to the buffer
12
- * If buffer is full, oldest entry is removed
17
+ * Add an entry to the buffer — O(1) always.
18
+ * If buffer is full, the oldest entry is overwritten.
13
19
  */
14
20
  push(entry: CapturedEntry): void;
15
21
  /**
16
- * Get all entries (optionally filtered)
22
+ * Get a single entry by logical index (0 = oldest). O(1).
23
+ * Returns undefined if index is out of range.
24
+ */
25
+ get(index: number): CapturedEntry | undefined;
26
+ /**
27
+ * Get a range of entries [start, end) by logical index. O(end - start).
28
+ * Clamps to valid range. Returns a new array.
29
+ */
30
+ getRange(start: number, end: number): CapturedEntry[];
31
+ /**
32
+ * Get all entries in insertion order (optionally filtered).
33
+ * Allocates a new array — used for full renders, not the hot path.
17
34
  */
18
35
  getEntries(filter?: (entry: CapturedEntry) => boolean): CapturedEntry[];
19
36
  /**
@@ -1,44 +1,87 @@
1
1
  /**
2
- * Ring buffer for captured log entries
2
+ * Ring buffer for captured log entries.
3
+ *
4
+ * Uses a fixed-size array with a head pointer so that push() is always O(1).
5
+ * The previous implementation used Array.push + Array.shift which is O(n)
6
+ * once the buffer is full (every shift copies all elements forward).
3
7
  */
4
8
  export class LogBuffer {
5
- entries = [];
9
+ buf;
10
+ head = 0; // index of the oldest entry
11
+ count = 0; // number of live entries
6
12
  maxSize;
7
13
  _totalPushed = 0;
8
14
  constructor(maxSize = 2000) {
9
15
  this.maxSize = maxSize;
16
+ this.buf = new Array(maxSize);
10
17
  }
11
18
  /**
12
- * Add an entry to the buffer
13
- * If buffer is full, oldest entry is removed
19
+ * Add an entry to the buffer — O(1) always.
20
+ * If buffer is full, the oldest entry is overwritten.
14
21
  */
15
22
  push(entry) {
16
23
  this._totalPushed++;
17
- this.entries.push(entry);
18
- if (this.entries.length > this.maxSize) {
19
- this.entries.shift(); // Remove oldest
24
+ if (this.count < this.maxSize) {
25
+ // Buffer not yet full — append at head + count
26
+ this.buf[(this.head + this.count) % this.maxSize] = entry;
27
+ this.count++;
28
+ }
29
+ else {
30
+ // Overwrite oldest entry, advance head
31
+ this.buf[this.head] = entry;
32
+ this.head = (this.head + 1) % this.maxSize;
20
33
  }
21
34
  }
22
35
  /**
23
- * Get all entries (optionally filtered)
36
+ * Get a single entry by logical index (0 = oldest). O(1).
37
+ * Returns undefined if index is out of range.
38
+ */
39
+ get(index) {
40
+ if (index < 0 || index >= this.count)
41
+ return undefined;
42
+ return this.buf[(this.head + index) % this.maxSize];
43
+ }
44
+ /**
45
+ * Get a range of entries [start, end) by logical index. O(end - start).
46
+ * Clamps to valid range. Returns a new array.
47
+ */
48
+ getRange(start, end) {
49
+ const s = Math.max(0, start);
50
+ const e = Math.min(this.count, end);
51
+ const result = [];
52
+ for (let i = s; i < e; i++) {
53
+ result.push(this.buf[(this.head + i) % this.maxSize]);
54
+ }
55
+ return result;
56
+ }
57
+ /**
58
+ * Get all entries in insertion order (optionally filtered).
59
+ * Allocates a new array — used for full renders, not the hot path.
24
60
  */
25
61
  getEntries(filter) {
26
- if (!filter)
27
- return [...this.entries];
28
- return this.entries.filter(filter);
62
+ const result = [];
63
+ for (let i = 0; i < this.count; i++) {
64
+ const entry = this.buf[(this.head + i) % this.maxSize];
65
+ if (!filter || filter(entry)) {
66
+ result.push(entry);
67
+ }
68
+ }
69
+ return result;
29
70
  }
30
71
  /**
31
72
  * Clear all entries
32
73
  */
33
74
  clear() {
34
- this.entries = [];
75
+ this.buf = new Array(this.maxSize);
76
+ this.head = 0;
77
+ this.count = 0;
35
78
  this._totalPushed = 0;
36
79
  }
37
80
  /**
38
81
  * Get entry count
39
82
  */
40
83
  get size() {
41
- return this.entries.length;
84
+ return this.count;
42
85
  }
43
86
  /**
44
87
  * Get total entries ever pushed (including evicted ones)
@@ -50,7 +93,7 @@ export class LogBuffer {
50
93
  * Get number of entries evicted due to buffer overflow
51
94
  */
52
95
  get evicted() {
53
- return this._totalPushed - this.entries.length;
96
+ return this._totalPushed - this.count;
54
97
  }
55
98
  /**
56
99
  * Get the maximum capacity
@@ -86,10 +86,12 @@ export async function loadEruda(options) {
86
86
  const ggPlugin = createGgPlugin(options, gg);
87
87
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
88
  eruda.add(ggPlugin);
89
- // Make GG tab the default selected tab
89
+ // Select GG tab as default (but don't open the panel)
90
90
  eruda.show('GG');
91
- // Expand the Eruda panel (open from minimized floating button state)
92
- eruda.show();
91
+ // Open the panel if requested
92
+ if (options.open) {
93
+ eruda.show();
94
+ }
93
95
  // Run diagnostics after Eruda is ready so they appear in Console tab
94
96
  await runGgDiagnostics();
95
97
  }
@@ -1,5 +1,6 @@
1
1
  import { DEV } from 'esm-env';
2
2
  import { LogBuffer } from './buffer.js';
3
+ import { Virtualizer, elementScroll, observeElementOffset, observeElementRect, measureElement } from '@tanstack/virtual-core';
3
4
  const _ggCallSitesPlugin = typeof __GG_TAG_PLUGIN__ !== 'undefined' ? __GG_TAG_PLUGIN__ : false;
4
5
  /**
5
6
  * Creates the gg Eruda plugin
@@ -19,8 +20,15 @@ export function createGgPlugin(options, gg) {
19
20
  let filterExpanded = false;
20
21
  let filterPattern = '';
21
22
  const enabledNamespaces = new Set();
22
- // Last rendered entries (for hover tooltip arg lookup)
23
- let renderedEntries = [];
23
+ // Virtual scroll: filtered indices into the buffer (the "rows" the virtualizer sees)
24
+ let filteredIndices = [];
25
+ // Virtual scroll: the @tanstack/virtual-core Virtualizer instance
26
+ let virtualizer = null;
27
+ // Virtual scroll: track expanded state so it survives re-renders.
28
+ // Keys are the uniqueId strings (e.g. "42-0") for object details and
29
+ // stack IDs (e.g. "stack-42") for stack traces.
30
+ const expandedDetails = new Set();
31
+ const expandedStacks = new Set();
24
32
  // Toast state for "namespace hidden" feedback
25
33
  let lastHiddenPattern = null; // filterPattern before the hide (for undo)
26
34
  let hasSeenToastExplanation = false; // first toast auto-expands help text
@@ -120,9 +128,9 @@ export function createGgPlugin(options, gg) {
120
128
  const time = new Date(entry.timestamp).toISOString().slice(11, 23);
121
129
  // Trim namespace and strip 'gg:' prefix to save tokens
122
130
  const ns = entry.namespace.trim().replace(/^gg:/, '');
123
- // Include expression suffix when toggle is enabled
131
+ // Include expression on its own line above the value when toggle is enabled
124
132
  const hasSrcExpr = !entry.level && entry.src?.trim() && !/^['"`]/.test(entry.src);
125
- const exprSuffix = includeExpressions && hasSrcExpr ? ` \u2039${entry.src}\u203A` : '';
133
+ const exprLine = includeExpressions && hasSrcExpr ? `\u2039${entry.src}\u203A\n` : '';
126
134
  // Format args: compact JSON for objects, primitives as-is
127
135
  const argsStr = entry.args
128
136
  .map((arg) => {
@@ -133,7 +141,7 @@ export function createGgPlugin(options, gg) {
133
141
  return stripAnsi(String(arg));
134
142
  })
135
143
  .join(' ');
136
- return `${time} ${ns} ${argsStr}${exprSuffix}`;
144
+ return `${exprLine}${time} ${ns} ${argsStr}`;
137
145
  }
138
146
  const plugin = {
139
147
  name: 'GG',
@@ -225,8 +233,19 @@ export function createGgPlugin(options, gg) {
225
233
  if (gg) {
226
234
  gg._onLog = null;
227
235
  }
236
+ // Clean up virtualizer
237
+ if (virtualizer && $el) {
238
+ const containerDom = $el.find('.gg-log-container').get(0);
239
+ if (containerDom) {
240
+ const cleanup = containerDom.__ggVirtualCleanup;
241
+ if (cleanup)
242
+ cleanup();
243
+ }
244
+ virtualizer = null;
245
+ }
228
246
  buffer.clear();
229
247
  allNamespacesSet.clear();
248
+ filteredIndices = [];
230
249
  }
231
250
  };
232
251
  function toggleNamespace(namespace, enable) {
@@ -369,7 +388,10 @@ export function createGgPlugin(options, gg) {
369
388
  function gridColumns() {
370
389
  const ns = nsColWidth !== null ? `${nsColWidth}px` : 'auto';
371
390
  // Grid columns: diff | ns | handle | content
372
- return `auto ${ns} 4px 1fr`;
391
+ // Diff uses a fixed width (3.5em) instead of auto to avoid column jitter
392
+ // when virtual scroll swaps rows in/out — only ~50 rows are in the DOM
393
+ // at a time so auto would resize based on visible subset.
394
+ return `3.5em ${ns} 4px 1fr`;
373
395
  }
374
396
  function buildHTML() {
375
397
  return `
@@ -380,9 +402,11 @@ export function createGgPlugin(options, gg) {
380
402
  column-gap: 0;
381
403
  align-items: start !important;
382
404
  }
383
- /* Desktop: hide wrapper divs, show direct children */
405
+ /* Virtual scroll: each entry is a subgrid row with measurable height */
384
406
  .gg-log-entry {
385
- display: contents;
407
+ display: grid;
408
+ grid-template-columns: subgrid;
409
+ grid-column: 1 / -1;
386
410
  }
387
411
  .gg-log-header {
388
412
  display: contents;
@@ -395,6 +419,11 @@ export function createGgPlugin(options, gg) {
395
419
  align-self: start !important;
396
420
  border-top: 1px solid rgba(0,0,0,0.05);
397
421
  }
422
+ /* Virtual scroll: spacer provides total height for scrollbar */
423
+ .gg-virtual-spacer {
424
+ position: relative;
425
+ width: 100%;
426
+ }
398
427
  .gg-reset-filter-btn:hover {
399
428
  background: #1976D2 !important;
400
429
  transform: translateY(-1px);
@@ -1659,7 +1688,20 @@ export function createGgPlugin(options, gg) {
1659
1688
  return;
1660
1689
  const details = containerEl.querySelector(`.gg-details[data-index="${index}"]`);
1661
1690
  if (details) {
1662
- details.style.display = details.style.display === 'none' ? 'block' : 'none';
1691
+ const nowVisible = details.style.display === 'none';
1692
+ details.style.display = nowVisible ? 'block' : 'none';
1693
+ // Track state so it survives virtual scroll re-renders
1694
+ if (nowVisible) {
1695
+ expandedDetails.add(index);
1696
+ }
1697
+ else {
1698
+ expandedDetails.delete(index);
1699
+ }
1700
+ // Re-measure the entry so virtualizer adjusts total height
1701
+ const entryEl = details.closest('.gg-log-entry');
1702
+ if (entryEl && virtualizer) {
1703
+ virtualizer.measureElement(entryEl);
1704
+ }
1663
1705
  }
1664
1706
  return;
1665
1707
  }
@@ -1673,6 +1715,18 @@ export function createGgPlugin(options, gg) {
1673
1715
  const isExpanded = stackEl.classList.contains('expanded');
1674
1716
  stackEl.classList.toggle('expanded');
1675
1717
  target.textContent = isExpanded ? '▶ stack' : '▼ stack';
1718
+ // Track state so it survives virtual scroll re-renders
1719
+ if (isExpanded) {
1720
+ expandedStacks.delete(stackId);
1721
+ }
1722
+ else {
1723
+ expandedStacks.add(stackId);
1724
+ }
1725
+ // Re-measure the entry so virtualizer adjusts total height
1726
+ const entryEl = stackEl.closest('.gg-log-entry');
1727
+ if (entryEl && virtualizer) {
1728
+ virtualizer.measureElement(entryEl);
1729
+ }
1676
1730
  }
1677
1731
  return;
1678
1732
  }
@@ -1817,7 +1871,7 @@ export function createGgPlugin(options, gg) {
1817
1871
  const entryIdx = entryEl?.getAttribute('data-entry');
1818
1872
  if (entryIdx === null || entryIdx === undefined)
1819
1873
  return;
1820
- const entry = renderedEntries[Number(entryIdx)];
1874
+ const entry = buffer.get(Number(entryIdx));
1821
1875
  if (!entry)
1822
1876
  return;
1823
1877
  e.preventDefault();
@@ -1839,7 +1893,7 @@ export function createGgPlugin(options, gg) {
1839
1893
  const argIdx = target.getAttribute('data-arg');
1840
1894
  if (entryIdx === null || argIdx === null)
1841
1895
  return;
1842
- const entry = renderedEntries[Number(entryIdx)];
1896
+ const entry = buffer.get(Number(entryIdx));
1843
1897
  if (!entry)
1844
1898
  return;
1845
1899
  const arg = entry.args[Number(argIdx)];
@@ -1992,8 +2046,11 @@ export function createGgPlugin(options, gg) {
1992
2046
  containerEl.addEventListener('pointerup', onPointerUp);
1993
2047
  resizeAttached = true;
1994
2048
  }
1995
- /** Build the HTML string for a single log entry */
1996
- function renderEntryHTML(entry, index) {
2049
+ /** Build the HTML string for a single log entry.
2050
+ * @param index Buffer index (used for data-entry, expand IDs, tooltip lookup)
2051
+ * @param virtualIndex Position in filteredIndices (used by virtualizer for measurement)
2052
+ */
2053
+ function renderEntryHTML(entry, index, virtualIndex) {
1997
2054
  const color = entry.color || '#0066cc';
1998
2055
  const diff = `+${humanize(entry.diff)}`;
1999
2056
  // Split namespace into clickable segments on multiple delimiters: : @ / - _
@@ -2065,16 +2122,19 @@ export function createGgPlugin(options, gg) {
2065
2122
  const uniqueId = `${index}-${argIdx}`;
2066
2123
  // Expression header inside expanded details
2067
2124
  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>`;
2125
+ // Store details separately to render after the row.
2126
+ // Restore expanded state from expandedDetails set so it
2127
+ // survives virtual scroll re-renders.
2128
+ const detailsVisible = expandedDetails.has(uniqueId);
2129
+ detailsHTML += `<div class="gg-details" data-index="${uniqueId}" style="display: ${detailsVisible ? 'block' : '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
2130
  // data-entry/data-arg for hover tooltip lookup, data-src for expression context
2071
2131
  const srcAttr = srcExpr ? ` data-src="${srcExpr}"` : '';
2072
2132
  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>`
2133
+ // Show expression on its own line above the value when toggle is enabled
2134
+ const exprAbove = showExpressions && srcExpr
2135
+ ? `<div class="gg-inline-expr">\u2039${srcExpr}\u203A</div>`
2076
2136
  : '';
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>`;
2137
+ return `${exprAbove}<span style="color: #888; cursor: pointer; text-decoration: underline;" class="gg-expand" data-index="${uniqueId}" data-entry="${index}" data-arg="${argIdx}"${srcAttr}>${srcIcon}${preview}</span>`;
2078
2138
  }
2079
2139
  else {
2080
2140
  // Parse ANSI codes first, then convert URLs to clickable links
@@ -2097,27 +2157,31 @@ export function createGgPlugin(options, gg) {
2097
2157
  : entry.level === 'error'
2098
2158
  ? ' gg-level-error'
2099
2159
  : '';
2100
- // Stack trace toggle (for error/trace entries with captured stacks)
2160
+ // Stack trace toggle (for error/trace entries with captured stacks).
2161
+ // Restore expanded state from expandedStacks set so it survives
2162
+ // virtual scroll re-renders.
2101
2163
  let stackHTML = '';
2102
2164
  if (entry.stack) {
2103
2165
  const stackId = `stack-${index}`;
2166
+ const stackExpanded = expandedStacks.has(stackId);
2104
2167
  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>`;
2168
+ `<span class="gg-stack-toggle" data-stack-id="${stackId}">${stackExpanded ? '▼' : '▶'} stack</span>` +
2169
+ `<div class="gg-stack-content${stackExpanded ? ' expanded' : ''}" data-stack-id="${stackId}">${escapeHtml(entry.stack)}</div>`;
2107
2170
  }
2108
2171
  // Expression tooltip: skip table entries (tableData) -- expression is just gg.table(...) which isn't useful
2109
2172
  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>`
2173
+ // For primitives-only entries, show expression on its own line above the value when showExpressions is enabled
2174
+ const exprAboveForPrimitives = showExpressions && hasSrcExpr && !argsHTML.includes('gg-expand')
2175
+ ? `<div class="gg-inline-expr">\u2039${escapeHtml(entry.src)}\u203A</div>`
2113
2176
  : '';
2114
- return (`<div class="gg-log-entry${levelClass}" data-entry="${index}">` +
2177
+ const vindexAttr = virtualIndex !== undefined ? ` data-vindex="${virtualIndex}"` : '';
2178
+ return (`<div class="gg-log-entry${levelClass}" data-entry="${index}"${vindexAttr}>` +
2115
2179
  `<div class="gg-log-header">` +
2116
2180
  `<div class="gg-log-diff" style="color: ${color};"${fileAttr}${lineAttr}${colAttr}>${diff}</div>` +
2117
2181
  `<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
2182
  `<div class="gg-log-handle"></div>` +
2119
2183
  `</div>` +
2120
- `<div class="gg-log-content"${hasSrcExpr ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${argsHTML}${inlineExprForPrimitives}${stackHTML}</div>` +
2184
+ `<div class="gg-log-content"${hasSrcExpr ? ` data-src="${escapeHtml(entry.src)}"` : ''}>${exprAboveForPrimitives}${argsHTML}${stackHTML}</div>` +
2121
2185
  detailsHTML +
2122
2186
  `</div>`);
2123
2187
  }
@@ -2146,21 +2210,129 @@ export function createGgPlugin(options, gg) {
2146
2210
  if (!copyCountSpan.length)
2147
2211
  return;
2148
2212
  const allCount = buffer.size;
2149
- const visibleCount = renderedEntries.length;
2213
+ const visibleCount = filteredIndices.length;
2150
2214
  const countText = visibleCount === allCount
2151
2215
  ? `Copy ${visibleCount} ${visibleCount === 1 ? 'entry' : 'entries'}`
2152
2216
  : `Copy ${visibleCount} / ${allCount} ${visibleCount === 1 ? 'entry' : 'entries'}`;
2153
2217
  copyCountSpan.html(countText);
2154
2218
  }
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;
2219
+ // ─── Virtual scroll helpers ───────────────────────────────────────────
2220
+ /** Rebuild the filtered index list from the buffer. */
2221
+ function rebuildFilteredIndices() {
2222
+ filteredIndices = [];
2223
+ for (let i = 0; i < buffer.size; i++) {
2224
+ const entry = buffer.get(i);
2225
+ if (enabledNamespaces.has(entry.namespace)) {
2226
+ filteredIndices.push(i);
2227
+ }
2228
+ }
2161
2229
  }
2230
+ /** Track whether user is near bottom (for auto-scroll decisions). */
2231
+ let userNearBottom = true;
2162
2232
  /**
2163
- * Incremental append: add new entries to the existing DOM without full rebuild.
2233
+ * Render the visible virtual items into the DOM grid.
2234
+ * Called by the virtualizer's onChange and after new entries arrive.
2235
+ */
2236
+ let isRendering = false;
2237
+ function renderVirtualItems() {
2238
+ // Guard against re-entrant calls (measureElement → onChange → renderVirtualItems)
2239
+ if (isRendering)
2240
+ return;
2241
+ if (!$el || !virtualizer)
2242
+ return;
2243
+ const containerDom = $el.find('.gg-log-container').get(0);
2244
+ if (!containerDom)
2245
+ return;
2246
+ const spacer = containerDom.querySelector('.gg-virtual-spacer');
2247
+ const grid = containerDom.querySelector('.gg-log-grid');
2248
+ if (!spacer || !grid)
2249
+ return;
2250
+ const items = virtualizer.getVirtualItems();
2251
+ if (items.length === 0) {
2252
+ grid.innerHTML = '';
2253
+ return;
2254
+ }
2255
+ // Set the spacer's height so the scrollbar reflects the full virtual list
2256
+ spacer.style.height = `${virtualizer.getTotalSize()}px`;
2257
+ // Position the grid at the start offset of the first visible item
2258
+ const startOffset = items[0].start;
2259
+ grid.style.transform = `translateY(${startOffset}px)`;
2260
+ // Build HTML only for visible items
2261
+ const html = items
2262
+ .map((item) => {
2263
+ const bufferIdx = filteredIndices[item.index];
2264
+ const entry = buffer.get(bufferIdx);
2265
+ if (!entry)
2266
+ return '';
2267
+ return renderEntryHTML(entry, bufferIdx, item.index);
2268
+ })
2269
+ .join('');
2270
+ grid.innerHTML = html;
2271
+ // After inserting HTML, measure each rendered entry so the virtualizer
2272
+ // learns actual heights (drives dynamic sizing).
2273
+ isRendering = true;
2274
+ try {
2275
+ const entryEls = grid.querySelectorAll('.gg-log-entry');
2276
+ entryEls.forEach((el) => {
2277
+ virtualizer.measureElement(el);
2278
+ });
2279
+ }
2280
+ finally {
2281
+ isRendering = false;
2282
+ }
2283
+ }
2284
+ /**
2285
+ * Create or reconfigure the virtualizer for the current filteredIndices.
2286
+ * Call after filter changes or full rebuilds.
2287
+ */
2288
+ function setupVirtualizer(scrollToBottom) {
2289
+ if (!$el)
2290
+ return;
2291
+ const containerDom = $el.find('.gg-log-container').get(0);
2292
+ if (!containerDom)
2293
+ return;
2294
+ // Tear down previous virtualizer
2295
+ if (virtualizer) {
2296
+ virtualizer.setOptions({
2297
+ ...virtualizer.options,
2298
+ count: 0,
2299
+ enabled: false
2300
+ });
2301
+ virtualizer = null;
2302
+ }
2303
+ const count = filteredIndices.length;
2304
+ if (count === 0)
2305
+ return;
2306
+ virtualizer = new Virtualizer({
2307
+ count,
2308
+ getScrollElement: () => containerDom,
2309
+ estimateSize: () => 24, // estimated row height in px
2310
+ overscan: 10,
2311
+ observeElementRect,
2312
+ observeElementOffset,
2313
+ scrollToFn: elementScroll,
2314
+ measureElement: (el, entry, instance) => measureElement(el, entry, instance),
2315
+ // Use buffer index as the stable key for each virtual row
2316
+ getItemKey: (index) => filteredIndices[index],
2317
+ // The data-index attribute TanStack uses to find elements for measurement
2318
+ indexAttribute: 'data-vindex',
2319
+ onChange: () => {
2320
+ renderVirtualItems();
2321
+ }
2322
+ });
2323
+ // Mount the virtualizer (attaches scroll/resize observers)
2324
+ const cleanup = virtualizer._didMount();
2325
+ // Store cleanup for when we tear down (TODO: call on destroy)
2326
+ containerDom.__ggVirtualCleanup = cleanup;
2327
+ // Initial render, then scroll to bottom if requested
2328
+ virtualizer._willUpdate();
2329
+ renderVirtualItems();
2330
+ if (scrollToBottom) {
2331
+ virtualizer.scrollToIndex(count - 1, { align: 'end' });
2332
+ }
2333
+ }
2334
+ /**
2335
+ * Incremental append: add new entries to the virtual scroll.
2164
2336
  * Called from the rAF-batched _onLog path.
2165
2337
  */
2166
2338
  function appendLogs(newEntries) {
@@ -2169,47 +2341,50 @@ export function createGgPlugin(options, gg) {
2169
2341
  const logContainer = $el.find('.gg-log-container');
2170
2342
  if (!logContainer.length)
2171
2343
  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
2344
  const containerDom = logContainer.get(0);
2176
- const grid = containerDom?.querySelector('.gg-log-grid');
2177
- if (!grid || !containerDom) {
2345
+ if (!containerDom)
2346
+ return;
2347
+ // Check if we need a full render (no grid yet, or empty state showing)
2348
+ const grid = containerDom.querySelector('.gg-log-grid');
2349
+ if (!grid) {
2178
2350
  renderLogs();
2179
2351
  return;
2180
2352
  }
2181
- if (visibleNew.length === 0) {
2182
- // New entries were all filtered out, just update count
2353
+ // Check if any new entries pass the filter
2354
+ const hasVisible = newEntries.some((e) => enabledNamespaces.has(e.namespace));
2355
+ if (!hasVisible && buffer.evicted === 0) {
2183
2356
  updateCopyCount();
2184
2357
  return;
2185
2358
  }
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
- }
2359
+ // Rebuild filteredIndices from scratch. This is O(buffer.size) with a
2360
+ // Set lookup per entry — ~0.1ms for 2000 entries. Always correct even
2361
+ // when the buffer wraps and old logical indices shift.
2362
+ rebuildFilteredIndices();
2207
2363
  updateCopyCount();
2208
2364
  updateTruncationBanner();
2209
- // Re-wire expanders after rendering
2365
+ // Check if user is near bottom before we update the virtualizer
2366
+ const nearBottom = containerDom.scrollHeight - containerDom.scrollTop - containerDom.clientHeight < 50;
2367
+ userNearBottom = nearBottom;
2368
+ // Update virtualizer count and re-render
2369
+ if (virtualizer) {
2370
+ virtualizer.setOptions({
2371
+ ...virtualizer.options,
2372
+ count: filteredIndices.length,
2373
+ getItemKey: (index) => filteredIndices[index]
2374
+ });
2375
+ virtualizer._willUpdate();
2376
+ // Render first so spacer height is updated, then scroll
2377
+ renderVirtualItems();
2378
+ if (userNearBottom) {
2379
+ virtualizer.scrollToIndex(filteredIndices.length - 1, { align: 'end' });
2380
+ }
2381
+ }
2382
+ else {
2383
+ // First entries — set up the virtualizer
2384
+ setupVirtualizer(true);
2385
+ }
2386
+ // Re-wire expanders (idempotent — only attaches once)
2210
2387
  wireUpExpanders();
2211
- // Smart auto-scroll
2212
- autoScroll(containerDom);
2213
2388
  }
2214
2389
  /** Full render: rebuild the entire log view (used for filter changes, clear, show, etc.) */
2215
2390
  function renderLogs() {
@@ -2218,16 +2393,27 @@ export function createGgPlugin(options, gg) {
2218
2393
  const logContainer = $el.find('.gg-log-container');
2219
2394
  if (!logContainer.length)
2220
2395
  return;
2221
- const allEntries = buffer.getEntries();
2222
- // Apply filtering
2223
- const entries = allEntries.filter((entry) => enabledNamespaces.has(entry.namespace));
2224
- renderedEntries = entries;
2396
+ // Clear expansion state on full rebuild
2397
+ expandedDetails.clear();
2398
+ expandedStacks.clear();
2399
+ // Rebuild filtered indices from scratch
2400
+ rebuildFilteredIndices();
2225
2401
  updateCopyCount();
2226
2402
  updateTruncationBanner();
2227
- if (entries.length === 0) {
2228
- const hasFilteredLogs = allEntries.length > 0;
2403
+ if (filteredIndices.length === 0) {
2404
+ // Tear down virtualizer
2405
+ if (virtualizer) {
2406
+ const containerDom = logContainer.get(0);
2407
+ if (containerDom) {
2408
+ const cleanup = containerDom.__ggVirtualCleanup;
2409
+ if (cleanup)
2410
+ cleanup();
2411
+ }
2412
+ virtualizer = null;
2413
+ }
2414
+ const hasFilteredLogs = buffer.size > 0;
2229
2415
  const message = hasFilteredLogs
2230
- ? `All ${allEntries.length} logs filtered out.`
2416
+ ? `All ${buffer.size} logs filtered out.`
2231
2417
  : 'No logs captured yet. Call gg() to see output here.';
2232
2418
  const resetButton = hasFilteredLogs
2233
2419
  ? '<button class="gg-reset-filter-btn" style="margin-top: 12px; padding: 10px 20px; cursor: pointer; border: 1px solid #2196F3; background: #2196F3; color: white; border-radius: 6px; font-size: 13px; font-weight: 500; transition: background 0.2s;">Show all logs (gg:*)</button>'
@@ -2235,23 +2421,18 @@ export function createGgPlugin(options, gg) {
2235
2421
  logContainer.html(`<div style="padding: 20px; text-align: center; opacity: 0.5;">${message}<div>${resetButton}</div></div>`);
2236
2422
  return;
2237
2423
  }
2238
- const logsHTML = `<div class="gg-log-grid${filterExpanded ? ' filter-mode' : ''}${showExpressions ? ' gg-show-expr' : ''}" style="grid-template-columns: ${gridColumns()};">${entries
2239
- .map((entry, index) => renderEntryHTML(entry, index))
2240
- .join('')}</div>`;
2241
- logContainer.html(logsHTML);
2242
- // Append hover tooltip div (destroyed by .html() each render, so re-create)
2243
- const containerDom = logContainer.get(0);
2244
- if (containerDom) {
2245
- const tip = document.createElement('div');
2246
- tip.className = 'gg-hover-tooltip';
2247
- containerDom.appendChild(tip);
2248
- }
2249
- // Re-wire expanders after rendering
2424
+ // Build the virtual scroll DOM structure:
2425
+ // - .gg-virtual-spacer: sized to total virtual height (provides scrollbar)
2426
+ // - .gg-log-grid: positioned absolutely, translated to visible offset, holds only visible entries
2427
+ const gridClasses = `gg-log-grid${filterExpanded ? ' filter-mode' : ''}${showExpressions ? ' gg-show-expr' : ''}`;
2428
+ logContainer.html(`<div class="gg-virtual-spacer">` +
2429
+ `<div class="${gridClasses}" style="position: absolute; top: 0; left: 0; width: 100%; grid-template-columns: ${gridColumns()};"></div>` +
2430
+ `</div>` +
2431
+ `<div class="gg-hover-tooltip"></div>`);
2432
+ // Re-wire event delegation (idempotent)
2250
2433
  wireUpExpanders();
2251
- // Auto-scroll to bottom
2252
- const el = logContainer.get(0);
2253
- if (el)
2254
- el.scrollTop = el.scrollHeight;
2434
+ // Create virtualizer and render
2435
+ setupVirtualizer(true);
2255
2436
  }
2256
2437
  /** Format ms like debug's `ms` package: 0ms, 500ms, 5s, 2m, 1h, 3d */
2257
2438
  function humanize(ms) {
@@ -17,6 +17,11 @@ export interface GgErudaOptions {
17
17
  * @default {}
18
18
  */
19
19
  erudaOptions?: Record<string, unknown>;
20
+ /**
21
+ * Whether to open the GgConsole panel on load (not just the floating icon)
22
+ * @default false
23
+ */
24
+ open?: boolean;
20
25
  }
21
26
  /** Log severity level */
22
27
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
@@ -747,7 +747,9 @@ export function transformGgCalls(code, shortPath, filePath, svelteInfo, jsFuncti
747
747
  }
748
748
  // Extract the first argument (the namespace string)
749
749
  // Look for the string literal after 'gg.ns('
750
- const afterNsParen = i + 6; // position after 'gg.ns('
750
+ let afterNsParen = i + 6; // position after 'gg.ns('
751
+ while (afterNsParen < code.length && /\s/.test(code[afterNsParen]))
752
+ afterNsParen++;
751
753
  const quoteChar = code[afterNsParen];
752
754
  if (quoteChar === "'" || quoteChar === '"') {
753
755
  // Find the closing quote
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftium/gg",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Leftium/gg.git"
@@ -70,6 +70,7 @@
70
70
  ],
71
71
  "dependencies": {
72
72
  "@sveltejs/acorn-typescript": "^1.0.9",
73
+ "@tanstack/virtual-core": "^3.13.18",
73
74
  "@types/estree": "^1.0.8",
74
75
  "acorn": "^8.15.0",
75
76
  "dotenv": "^17.2.4",