Skip to content
Merged
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
5 changes: 3 additions & 2 deletions src-tauri/src/db/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1731,8 +1731,9 @@ pub async fn execute_sql_on_conn(

/// Hard row cap for ad-hoc SQL execution. Prevents OOM on tables with millions of rows.
const EXECUTE_SQL_MAX_ROWS: usize = 1_000_000_000;
/// Statement timeout for ad-hoc queries (milliseconds).
const EXECUTE_SQL_TIMEOUT_MS: i64 = 30_000;
/// Statement timeout for ad-hoc queries (milliseconds). Generous enough for
/// heavier scans (e.g. tables with large TOASTed JSON columns) to finish.
const EXECUTE_SQL_TIMEOUT_MS: i64 = 60_000;

async fn execute_sql_pg(
pool: &sqlx::PgPool,
Expand Down
231 changes: 185 additions & 46 deletions src/lib/components/DataTable.svelte

Large diffs are not rendered by default.

46 changes: 37 additions & 9 deletions src/lib/components/ExtensionsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,19 @@
<!-- Sidebar -->
<aside class="flex w-[16.5rem] shrink-0 flex-col border-r border-border/50">
<div class="flex items-center justify-between gap-2 px-4 pb-3 pt-4">
<div class="flex items-center gap-2 min-w-0">
<Blocks class="size-4 text-muted-foreground" />
<div class="flex items-center gap-2.5 min-w-0">
<span class="grid size-6 shrink-0 place-items-center rounded-md border border-border/60 bg-muted/40 text-muted-foreground">
<Blocks class="size-3.5" />
</span>
<h1 class="text-[13px] font-semibold tracking-tight text-foreground">Extensions</h1>
</div>
<span class="shrink-0 text-[11px] tabular-nums text-muted-foreground/50">{enabledCount} on</span>
<span
class={cn(
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums transition-colors",
enabledCount > 0 ? "bg-emerald-500/12 text-emerald-500" : "text-muted-foreground/45",
)}
title="{enabledCount} of {EXTENSIONS.length} extensions enabled"
>{enabledCount} on</span>
</div>

<div class="app-scroll min-h-0 flex-1 overflow-y-auto px-2 pb-3">
Expand All @@ -193,13 +201,17 @@
type="button"
onclick={() => (selectedId = ext.id)}
class={cn(
"group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors",
"group relative flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors",
active ? "bg-muted/70 text-foreground" : "text-foreground/70 hover:bg-muted/40 hover:text-foreground",
)}
>
{#if active}<span class="absolute inset-y-1.5 left-0 w-0.5 rounded-full bg-primary" aria-hidden="true"></span>{/if}
{#if Icon}<Icon class={cn("size-3.5 shrink-0", active ? "text-foreground/80" : "text-muted-foreground/60")} />{/if}
<span class="min-w-0 flex-1 truncate text-[13px]">{ext.name}</span>
<span class={cn("size-1.5 shrink-0 rounded-full transition-colors", on ? "bg-emerald-500" : "bg-transparent")}></span>
<span
class={cn("size-1.5 shrink-0 rounded-full transition-all", on ? "bg-emerald-500 ring-2 ring-emerald-500/20" : "bg-transparent")}
title={on ? "Enabled" : undefined}
></span>
</button>
{/each}
{/each}
Expand All @@ -212,18 +224,34 @@
{@const Icon = ICONS[selected.id]}
{@const on = isOn(selected.id)}
<div class="mx-auto w-full max-w-[36rem] px-8 py-9">
<!-- Header card — accent-tinted icon reads correctly in every theme -->
<div class="rounded-xl border border-border/60 bg-card/40 p-4">
<!-- Header card — enabled extensions pick up a subtle emerald accent so
the on/off state reads at a glance; disabled stay neutral. -->
<div
class={cn(
"rounded-xl border p-4 transition-colors",
on ? "border-emerald-500/25 bg-emerald-500/[0.04]" : "border-border/60 bg-card/40",
)}
>
<div class="flex items-start gap-3.5">
<span class="grid size-10 shrink-0 place-items-center rounded-lg border border-primary/20 bg-primary/10 text-primary">
<span
class={cn(
"grid size-10 shrink-0 place-items-center rounded-lg border transition-colors",
on ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-500" : "border-primary/20 bg-primary/10 text-primary",
)}
>
{#if Icon}<Icon class="size-5" />{/if}
</span>
<div class="min-w-0 flex-1 pt-0.5">
<h2 class="text-[15px] font-semibold tracking-tight text-foreground">{selected.name}</h2>
<p class="mt-0.5 text-[11.5px] text-muted-foreground">{KIND_LABEL[selected.kind] ?? "Extension"}</p>
</div>
<label class="flex shrink-0 cursor-pointer items-center gap-2 pt-0.5">
<span class={cn("text-[11px] font-medium", on ? "text-emerald-500" : "text-muted-foreground/60")}>{on ? "On" : "Off"}</span>
<span
class={cn(
"rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide transition-colors",
on ? "bg-emerald-500/12 text-emerald-500" : "text-muted-foreground/55",
)}
>{on ? "On" : "Off"}</span>
{@render toggle(on, () => setPluginEnabled(selected.id, !on), `Toggle ${selected.name}`)}
</label>
</div>
Expand Down
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
1 change: 1 addition & 0 deletions src/lib/components/KeyboardShortcutsDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
{ keys: [mod, "⇧", "D"], desc: "Data view" },
{ keys: [mod, "⇧", "S"], desc: "SQL editor" },
{ keys: [mod, "⇧", "O"], desc: "ORM Runner" },
{ keys: [mod, "⇧", "X"], desc: "Extensions" },
{ keys: [mod, "⇧", "E"], desc: "Toggle AI panel" },
{ keys: [mod, "I"], desc: "Toggle AI sidebar" },
{ keys: [mod, "⇧", "L"], desc: "Activity log" },
Expand Down
45 changes: 45 additions & 0 deletions src/lib/components/PaneSnapshot.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script>
import DataTable from './DataTable.svelte'
import ShikiBlock from './ShikiBlock.svelte'
import Table2 from '@lucide/svelte/icons/table-2'
import PanelLeft from '@lucide/svelte/icons/panel-left'
import TerminalSquare from '@lucide/svelte/icons/terminal-square'
import { tabDisplayTitle } from '$lib/studio-tabs.js'

/** @typedef {import('$lib/studio-tabs.js').StudioTab} StudioTab */
/** @typedef {import('$lib/studio-tabs.js').TableTabState} TableTabState */
/** @typedef {import('$lib/studio-tabs.js').SqlTabState} SqlTabState */

let {
/** @type {StudioTab | null} */
Expand All @@ -15,6 +18,9 @@
const tableState = $derived(
tab?.kind === 'table' && tab.state ? /** @type {TableTabState} */ (tab.state) : null,
)
const sqlState = $derived(
tab?.kind === 'sql' && tab.state ? /** @type {SqlTabState} */ (tab.state) : null,
)

// Local, throwaway bindable state so DataTable's editing/selection machinery
// has somewhere to write. Background panes are read-only, so nothing here is
Expand Down Expand Up @@ -58,6 +64,45 @@
/>
</div>
{/if}
{:else if sqlState}
<!-- SQL / Query Editor snapshot: the query text plus its last results, read-only,
so a defocused pane still shows its work instead of an empty placeholder. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
{#if sqlState.sqlText?.trim()}
<div class="flex max-h-[38%] min-h-0 shrink-0 flex-col overflow-hidden border-b border-border/50">
<ShikiBlock code={sqlState.sqlText} lang="sql" />
</div>
{/if}
{#if sqlState.sqlColumns?.length}
<div class="flex min-h-0 min-w-0 flex-1">
<DataTable
columns={sqlState.sqlColumns}
rows={sqlState.sqlRows}
primaryKey={[]}
foreignKeys={[]}
loading={false}
readonly={true}
bind:selected
bind:focusedRow
bind:inspectorRow
bind:editingCell
/>
</div>
{:else}
<div class="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
<TerminalSquare class="size-6 text-muted-foreground/25" />
{#if sqlState.sqlError}
<p class="max-w-md font-mono text-ui-2xs text-destructive/70">{sqlState.sqlError}</p>
{:else if sqlState.sqlMessage}
<p class="font-mono text-ui-xs text-muted-foreground/60">{sqlState.sqlMessage}</p>
{:else}
<p class="font-mono text-ui-xs text-muted-foreground/50">
{sqlState.sqlText?.trim() ? 'No results yet' : 'Empty query'}
</p>
{/if}
</div>
{/if}
</div>
{:else}
<div class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<PanelLeft class="size-7 text-muted-foreground/25" />
Expand Down
10 changes: 6 additions & 4 deletions src/lib/components/QueryHistoryPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,13 @@
}} />

{#if visible}
<aside class="flex w-56 shrink-0 flex-col border-r border-border bg-panel">
<aside class="flex w-64 shrink-0 flex-col border-r border-border bg-panel">

<!-- Header row: segmented tabs + hide-panel toggle -->
<!-- Header row: segmented tabs + hide-panel toggle. overflow-hidden on the tab
group keeps the tabs from spilling over the editor toolbar if they ever
exceed the panel width. -->
<div class="flex h-9 shrink-0 items-center justify-between gap-1 border-b border-border pl-1 pr-1.5">
<div class="flex h-full min-w-0 items-stretch">
<div class="flex h-full min-w-0 items-stretch overflow-hidden">
{#each panelTabs as t (t.id)}
{@const Icon = t.icon}
{@const active = tab === t.id}
Expand All @@ -125,7 +127,7 @@
aria-current={active ? 'page' : undefined}
onclick={() => (tab = t.id)}
class={cn(
'group/pt relative flex h-full shrink-0 items-center gap-1.5 px-2.5 text-[11px] transition-colors',
'group/pt relative flex h-full shrink-0 items-center gap-1 px-2 text-[11px] transition-colors',
active
? 'font-medium text-foreground'
: 'font-normal text-muted-foreground/55 hover:text-foreground',
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
Loading