Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions src/lib/components/JsonTree.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,46 @@
)
const isContainer = $derived(isArray || isObject)

let expanded = $state(depth < defaultDepth)
let childLimit = $state(CHILD_PAGE)

// Total child count — cheap for arrays (.length). Avoids materializing a tuple
// for every element of a huge array/object just to show a count.
const totalCount = $derived(
isArray ? /** @type {unknown[]} */ (value).length
: isObject ? Object.keys(/** @type {Record<string, unknown>} */ (value)).length
: 0,
)

// Only the currently-visible children (up to childLimit) are materialized, so
// expanding a 100k-element array never builds 100k tuples at once.
/** @type {[string, unknown][]} */
const entries = $derived.by(() => {
if (isArray) return /** @type {unknown[]} */ (value).map((v, i) => /** @type {[string, unknown]} */ ([String(i), v]))
if (isObject) return Object.entries(/** @type {Record<string, unknown>} */ (value))
const visibleEntries = $derived.by(() => {
if (isArray) {
const arr = /** @type {unknown[]} */ (value)
const n = Math.min(arr.length, childLimit)
/** @type {[string, unknown][]} */
const out = []
for (let i = 0; i < n; i++) out.push([String(i), arr[i]])
return out
}
if (isObject) {
/** @type {[string, unknown][]} */
const out = []
let i = 0
for (const k in /** @type {Record<string, unknown>} */ (value)) {
if (i >= childLimit) break
out.push([k, /** @type {Record<string, unknown>} */ (value)[k]])
i++
}
return out
}
return []
})

let expanded = $state(depth < defaultDepth)
let childLimit = $state(CHILD_PAGE)

const summary = $derived.by(() => {
if (isArray) return entries.length === 1 ? '1 item' : `${entries.length} items`
if (isObject) return entries.length === 1 ? '1 key' : `${entries.length} keys`
if (isArray) return totalCount === 1 ? '1 item' : `${totalCount} items`
if (isObject) return totalCount === 1 ? '1 key' : `${totalCount} keys`
return ''
})

Expand Down Expand Up @@ -152,7 +179,7 @@

{#if isContainer && expanded}
<div class="border-l border-border/30">
{#each entries.slice(0, childLimit) as [k, v] (k)}
{#each visibleEntries as [k, v] (k)}
<JsonTree
value={v}
label={k}
Expand All @@ -162,13 +189,13 @@
{onopen}
/>
{/each}
{#if entries.length > childLimit}
{#if totalCount > childLimit}
<button
type="button"
class="ml-5 my-0.5 rounded bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onclick={() => (childLimit += CHILD_PAGE)}
>
Show {Math.min(CHILD_PAGE, entries.length - childLimit)} more ({entries.length - childLimit} hidden)
Show {Math.min(CHILD_PAGE, totalCount - childLimit)} more ({totalCount - childLimit} hidden)
</button>
{/if}
</div>
Expand Down
73 changes: 44 additions & 29 deletions src/lib/components/RowExpandViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
} from '$lib/json-inspector.js'

const TRUNCATE_LIMIT = 200
// Max array items / object keys kept in the raw-text preview. Bounds the
// stringified size for wide values (e.g. a Buffer's `data` byte array with
// thousands of numbers) so the preview never balloons. Copy JSON still copies
// the full, untruncated value.
const PREVIEW_CHILD_LIMIT = 200
const HIGHLIGHT_LIMIT = 12000

let {
Expand Down Expand Up @@ -60,43 +65,52 @@
/** @type {{ x: number, y: number, value: string | null } | null} */
let contextMenu = $state(null)

const displayText = $derived(formatJsonValue(truncateDeep(record, TRUNCATE_LIMIT)))
// Bounded preview of the record: strings truncated to TRUNCATE_LIMIT and
// arrays/objects capped to PREVIEW_CHILD_LIMIT children. `truncated` records
// whether anything was cut, which drives whether we highlight at all.
const displayInfo = $derived.by(() => {
let truncated = false
/** @param {unknown} v */
const walk = (v) => {
if (typeof v === 'string') {
if (v.length > TRUNCATE_LIMIT) { truncated = true; return v.slice(0, TRUNCATE_LIMIT) + '…' }
return v
}
if (Array.isArray(v)) {
const head = v.slice(0, PREVIEW_CHILD_LIMIT).map(walk)
if (v.length > PREVIEW_CHILD_LIMIT) { truncated = true; head.push(`… ${v.length - PREVIEW_CHILD_LIMIT} more items`) }
return head
}
if (v !== null && typeof v === 'object') {
/** @type {Record<string, unknown>} */
const out = {}
let i = 0
for (const k in v) {
if (i >= PREVIEW_CHILD_LIMIT) { truncated = true; break }
out[k] = walk(/** @type {Record<string, unknown>} */ (v)[k])
i++
}
return out
}
return v
}
return { text: formatJsonValue(walk(record)), truncated }
})
const displayText = $derived(displayInfo.text)

function fullJsonText() {
return formatJsonValue(record)
}

/**
* @param {unknown} value
* @param {number} limit
* @returns {unknown}
*/
function truncateDeep(value, limit) {
if (typeof value === 'string') {
return value.length > limit ? value.slice(0, limit) + '…' : value
}
if (Array.isArray(value)) {
return value.map((v) => truncateDeep(v, limit))
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(/** @type {Record<string, unknown>} */ (value)).map(([k, v]) => [
k,
truncateDeep(v, limit),
])
)
}
return value
}

$effect(() => {
// Tree mode never touches the stringified text — skip the format +
// highlight work entirely until the raw view is actually shown.
if (viewMode !== 'raw') return
const source = displayText
// Above the limit, skip per-token spans entirely — plain escaped text keeps
// very large values cheap to render.
html = source.length > HIGHLIGHT_LIMIT
const { text: source, truncated } = displayInfo
// Skip per-token highlighting for large values (anything we had to truncate,
// or a still-long preview) — the span-per-token HTML is the memory hog. Plain
// escaped text stays cheap to build and render.
html = truncated || source.length > HIGHLIGHT_LIMIT
? `<pre class="m-0 p-0 font-mono text-sm leading-relaxed whitespace-pre text-foreground">${escapeHtml(source)}</pre>`
: highlightJson(source)
})
Expand All @@ -107,7 +121,8 @@
$effect(() => {
if (!html || !rootEl) return
const source = displayText
if (source.length > HIGHLIGHT_LIMIT) return
// Plain (non-highlighted) render for large values — skip the link scan too.
if (displayInfo.truncated || source.length > HIGHLIGHT_LIMIT) return
void tick().then(() => {
const pre = rootEl?.querySelector('pre')
if (pre instanceof HTMLElement) linkifyJsonInElement(pre, source)
Expand Down
Loading