feat(datatables): navigate the database manager with a data table tree

This commit is contained in:
Diego Imbert
2026-08-25 14:21:17 +02:00
parent 665cadbca6
commit 4a20ac3ffd
3 changed files with 275 additions and 151 deletions
+247 -99
View File
@@ -6,6 +6,8 @@
Loader2,
Plus,
Table2,
Database as DatabaseIcon,
Folder as FolderIcon,
Trash2Icon,
UploadIcon
} from 'lucide-svelte'
@@ -21,18 +23,16 @@
import DbTableEditor from './DBTableEditor.svelte'
import type { DbType } from './dbTypes'
import Portal from './Portal.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import type { Snippet } from 'svelte'
import {
dbSupportsTransactionalDdl,
diffTableEditorValues
} from './apps/components/display/dbtable/queries/alterTable'
import { resource } from 'runed'
import type { Snippet } from 'svelte'
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import type { Asset } from '$lib/gen'
import type { Asset, DataTableTables } from '$lib/gen'
/** Represents a selected table with its schema */
export interface SelectedTable {
@@ -53,7 +53,15 @@
initialTableKey?: string
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
/** Every data table with its schemas and tables. Present only when the manager
* is on a data table — that is what puts a data-table level at the top of the
* tree; otherwise the tree starts at schemas. */
/** Multi-select pickers still choose their data table with a Select above the
* list; the tree below is the navigator for the manager's normal mode. */
dbSelector?: Snippet<[]>
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
@@ -78,6 +86,9 @@
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
dbSelector,
datatableTree,
datatableTreeLoading,
onSelectDatatable,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
@@ -152,6 +163,87 @@
}
let schemaKeys = $derived(Object.keys(dbSchema.schema ?? {}))
// --- Left-pane tree ---------------------------------------------------------
// Levels: data table -> schema -> table. The top two collapse away on their own
// terms: no `datatableTree` means this is not a data table, and a database
// without schemas has nothing to put between a data table and its tables.
const currentDatatable = $derived(
asset?.kind === 'datatable' ? asset.path : undefined
)
/** Tables per schema for a data table, as `schema -> table[]`. */
function schemasOf(datatable: string | undefined): Record<string, string[]> {
// The open data table reads from `dbSchema`, which is refetched after a DDL;
// the tree snapshot is not, so using it here would hide a table until the
// next full reload.
if (datatable === undefined || datatable === currentDatatable) {
return Object.fromEntries(
Object.entries(dbSchema.schema ?? {}).map(([sk, tables]) => [sk, Object.keys(tables ?? {})])
)
}
return datatableTree?.find((d) => d.datatable_name === datatable)?.schemas ?? {}
}
function errorOf(datatable: string): string | undefined {
return datatableTree?.find((d) => d.datatable_name === datatable)?.error
}
const matchesSearch = (t: string) => t.toLowerCase().includes(search.trim().toLowerCase())
/** The tree as rendered: only nodes with a matching descendant survive a search. */
let treeRoots = $derived.by(() => {
const datatables = datatableTree
? datatableTree.map((d) => d.datatable_name)
: [undefined as string | undefined]
return datatables.map((dt) => {
const schemas = Object.entries(schemasOf(dt))
.map(([schemaKey, tables]) => ({
schemaKey,
tables: tables.filter(matchesSearch).sort()
}))
.filter((sc) => search.trim() === '' || sc.tables.length > 0)
schemas.sort((a, b) => a.schemaKey.localeCompare(b.schemaKey))
return { datatable: dt, schemas, error: dt ? errorOf(dt) : undefined }
}).filter(
// A search narrows the tree to what matched; a data table with no match
// left in it would otherwise sit there as an empty row.
(root) => search.trim() === '' || root.schemas.length > 0
)
})
let expanded = $state<Set<string>>(new Set())
const nodeKey = (dt: string | undefined, schemaKey?: string) =>
`${dt ?? ''}${schemaKey === undefined ? '' : `/${schemaKey}`}`
function toggle(key: string) {
const next = new Set(expanded)
next.has(key) ? next.delete(key) : next.add(key)
expanded = next
}
// A node is open when explicitly expanded, when it holds the current selection,
// or when a search is narrowing the tree to what matched.
function isExpanded(dt: string | undefined, schemaKey?: string): boolean {
if (search.trim() !== '') return true
if (expanded.has(nodeKey(dt, schemaKey))) return true
if (dt !== undefined && dt !== currentDatatable) return false
return schemaKey === undefined || schemaKey === selected.schemaKey
}
function selectTable(dt: string | undefined, schemaKey: string, tableKey: string) {
if (dt !== undefined && dt !== currentDatatable) {
// Switching data table re-mounts this component against the new one, so
// the target has to travel through the bound keys the parent keeps —
// local state here is about to be thrown away.
selectedSchemaKey = schemaKey
selectedTableKey = tableKey
onSelectDatatable?.(dt)
return
}
selected = { schemaKey, tableKey }
}
let search = $state('')
let selected: {
schemaKey?: undefined | string
@@ -259,41 +351,9 @@
<Splitpanes>
<Pane size={24} class="relative flex flex-col">
<div class="mx-3 mt-3 flex flex-col gap-2">
{#if dbSelector}
{#if multiSelectMode && dbSelector}
{@render dbSelector()}
{/if}
{#if dbSupportsSchemas && !multiSelectMode}
<Select
bind:value={selected.schemaKey}
items={safeSelectItems(schemaKeys)}
id="db-schema-select"
transformInputSelectedText={(s) => `Schema: ${s}`}
RightIcon={ChevronDownIcon}
placeholder="Search or create schema..."
showPlaceholderOnOpen
onCreateItem={(schema) => {
schema = schema.trim().replace(/[^a-zA-Z0-9_]/g, '')
if (dbType === 'snowflake') schema = schema.toUpperCase()
askingForConfirmation = {
confirmationText: `Create ${schema}`,
type: 'reload',
title: `This will run 'CREATE SCHEMA ${schema}' on your database. Are you sure ?`,
open: true,
id: 'db-create-schema-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onCreateSchema({ schema })
refresh?.()
selected.schemaKey = schema
} finally {
askingForConfirmation = undefined
}
}
}
}}
/>
{/if}
<ClearableInput bind:value={search} placeholder="Search table..." />
</div>
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
@@ -462,69 +522,157 @@
</button>
{/each}
{:else}
<!-- Normal mode: show tables for selected schema -->
{#each filteredTableKeys as tableKey}
<!-- PLACEHOLDER -->
<button
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pl-3 pr-1 ' +
(selected.tableKey === tableKey ? 'bg-surface-secondary' : 'hover:bg-surface-hover')}
onclick={() => (selected.tableKey = tableKey)}
>
{#if asset}
<Star
kind="asset"
path={`${asset.kind}://${asset.path == 'main' ? '' : asset.path}/${selected.schemaKey}.${tableKey}`}
/>
{:else}
<Table2 class="text-primary shrink-0" size={14} />
{/if}
<p
class="db-manager-table-key truncate text-ellipsis grow text-left text-emphasis text-xs"
<!-- Normal mode: data table -> schema -> table, each level dropping out
when it has nothing to say (no data table / no schemas). -->
{#if datatableTreeLoading && (datatableTree?.length ?? 0) === 0}
<div class="flex items-center gap-2 text-tertiary p-3">
<Loader2 class="animate-spin" size={14} />
<span class="text-xs">Loading...</span>
</div>
{/if}
{#each treeRoots as root (root.datatable ?? '')}
{@const dtOpen = isExpanded(root.datatable)}
{#if root.datatable !== undefined}
<button
class={'w-full text-sm font-medium flex gap-2 items-center h-9 cursor-pointer pl-2 pr-1 hover:bg-gray-500/10 border-b border-surface-secondary ' +
(root.datatable === currentDatatable ? 'text-emphasis' : 'text-secondary')}
onclick={() => toggle(nodeKey(root.datatable))}
>
{tableKey}
</p>
<DropdownV2
items={() => [
{
displayName: 'Delete table',
icon: Trash2Icon,
action: () =>
(askingForConfirmation = {
title: `Are you sure you want to delete ${tableKey} ? This action is irreversible`,
confirmationText: 'Delete permanently',
open: true,
id: 'db-manager-delete-table-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onDelete({ tableKey, schema: selected.schemaKey })
refresh?.()
sendUserToast(`Table '${tableKey}' deleted successfully`)
} catch (e) {
let msg: string | undefined = (e as any).body ?? (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
askingForConfirmation = undefined
}
})
},
{
displayName: 'Alter table',
icon: EditIcon,
action: () => {
dbTableEditorState = {
open: true,
alterTableKey: tableKey
}
}
}
]}
class="w-fit"
btnId={'db-manager-table-actions-' + onlyAlphaNumAndUnderscore(tableKey)}
/>
</button>
<ChevronDownIcon
class={'shrink-0 transition-transform ' + (dtOpen ? '' : '-rotate-90')}
size={14}
/>
<DatabaseIcon class="shrink-0" size={14} />
<span class="truncate text-ellipsis grow text-left text-xs">{root.datatable}</span>
</button>
{/if}
{#if dtOpen}
{#if root.error}
<p class="text-xs text-red-400 px-3 py-2">{root.error}</p>
{/if}
{#each root.schemas as sc (sc.schemaKey)}
{@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)}
{@const indent = root.datatable !== undefined ? 'pl-6' : 'pl-2'}
{#if dbSupportsSchemas}
<button
class={'w-full text-sm font-medium flex gap-2 items-center h-9 cursor-pointer pr-1 hover:bg-gray-500/10 border-b border-surface-secondary ' +
indent}
onclick={() => toggle(nodeKey(root.datatable, sc.schemaKey))}
>
<ChevronDownIcon
class={'shrink-0 transition-transform ' + (schemaOpen ? '' : '-rotate-90')}
size={14}
/>
<FolderIcon class="shrink-0 text-tertiary" size={14} />
<span class="truncate text-ellipsis grow text-left text-xs">{sc.schemaKey}</span>
</button>
{/if}
{#if schemaOpen || !dbSupportsSchemas}
{@const tableIndent = dbSupportsSchemas
? root.datatable !== undefined
? 'pl-12'
: 'pl-8'
: root.datatable !== undefined
? 'pl-8'
: 'pl-3'}
{#each sc.tables as tableKey (tableKey)}
{@const isSelected =
root.datatable === currentDatatable &&
selected.schemaKey === sc.schemaKey &&
selected.tableKey === tableKey}
<button
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pr-1 ' +
tableIndent +
' ' +
(isSelected ? 'bg-surface-secondary' : 'hover:bg-surface-hover')}
onclick={() => selectTable(root.datatable, sc.schemaKey, tableKey)}
>
{#if asset}
<Star
kind="asset"
path={`${asset.kind}://${asset.path == 'main' ? '' : asset.path}/${sc.schemaKey}.${tableKey}`}
/>
{:else}
<Table2 class="text-primary shrink-0" size={14} />
{/if}
<p
class="db-manager-table-key truncate text-ellipsis grow text-left text-emphasis text-xs"
>
{tableKey}
</p>
{#if root.datatable === currentDatatable || root.datatable === undefined}
<DropdownV2
items={() => [
{
displayName: 'Delete table',
icon: Trash2Icon,
action: () =>
(askingForConfirmation = {
title: `Are you sure you want to delete ${tableKey} ? This action is irreversible`,
confirmationText: 'Delete permanently',
open: true,
id: 'db-manager-delete-table-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onDelete({
tableKey,
schema: sc.schemaKey
})
refresh?.()
sendUserToast(`Table '${tableKey}' deleted successfully`)
} catch (e) {
let msg: string | undefined =
(e as any).body ?? (e as Error).message
if (typeof msg !== 'string')
msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
askingForConfirmation = undefined
}
})
},
{
displayName: 'Alter table',
icon: EditIcon,
action: () => {
selected = { schemaKey: sc.schemaKey, tableKey }
dbTableEditorState = { open: true, alterTableKey: tableKey }
}
}
]}
class="w-fit"
btnId={'db-manager-table-actions-' + onlyAlphaNumAndUnderscore(tableKey)}
/>
{/if}
</button>
{/each}
{#if root.datatable === currentDatatable || root.datatable === undefined}
<button
class={'w-full text-sm font-normal flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 text-tertiary ' +
tableIndent}
onclick={() => {
selected = { schemaKey: sc.schemaKey, tableKey: undefined }
dbTableEditorState = { open: true }
}}
>
<Plus class="shrink-0" size={14} />
<span class="text-xs">New table</span>
</button>
{/if}
{/if}
{/each}
{#if dbSupportsSchemas && (root.datatable === currentDatatable || root.datatable === undefined) && search.trim() === ''}
<button
class={'w-full text-sm font-normal flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 text-tertiary ' +
(root.datatable !== undefined ? 'pl-6' : 'pl-2')}
onclick={() => (newSchemaDialogOpen = true)}
>
<Plus class="shrink-0" size={14} />
<span class="text-xs">New schema</span>
</button>
{/if}
{/if}
{/each}
{/if}
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import type { Snippet } from 'svelte'
import type { DataTableTables } from '$lib/gen'
import { sortArray } from '$lib/utils'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
@@ -18,7 +20,6 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import { type Snippet } from 'svelte'
import type { DbInput } from './dbTypes'
import { getDbSchemas, loadAllTablesMetaData } from './apps/components/display/dbtable/metadata'
@@ -36,7 +37,13 @@
hasReplResult?: boolean
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
/** Every data table with its schemas and tables, for the left-pane tree.
* Undefined when the drawer is not on a data table, which is what collapses
* the tree's top level away. */
dbSelector?: Snippet<[]>
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
@@ -60,6 +67,9 @@
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
dbSelector,
datatableTree,
datatableTreeLoading,
onSelectDatatable,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
@@ -309,6 +319,9 @@
{dbType}
refresh={() => refresh()}
{dbSelector}
{datatableTree}
{datatableTreeLoading}
{onSelectDatatable}
{onImport}
bind:selectedSchemaKey
bind:selectedTableKey
@@ -1,6 +1,6 @@
<script lang="ts">
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { WorkspaceService, type DataTableTables } from '$lib/gen'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
@@ -10,7 +10,6 @@
Copy,
Download,
Expand,
LoaderCircle,
Minimize,
RefreshCcw,
Upload
@@ -42,24 +41,19 @@
// the editor that opened it (set via openDrawer), else the nav workspace.
let ws = $derived(uriState.workspace ?? $workspaceStore)
// Load available datatables when drawer opens with datatable input
const datatables = resource<string[]>([], async () => {
// Every data table with its schemas and tables, in one call: this is what the
// left pane's tree navigates, so it has to cover the data tables the user is
// not currently on, not just the selected one.
const datatables = resource<DataTableTables[]>([], async () => {
if (!ws) return []
try {
return (await WorkspaceService.listDataTables({ workspace: ws })).map((d) => d.name)
return await WorkspaceService.listDataTableTables({ workspace: ws })
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
})
const datatableItems = $derived(
datatables.current.map((dt) => ({
value: dt,
label: dt
}))
)
// Roles the *caller* may use, so the picker never offers one that would be
// refused. Absent/disabled permissions yield no roles and hide the picker.
const usableRoles = resource(
@@ -67,7 +61,10 @@
async ([workspace, datatable]) => {
if (!workspace || !datatable) return undefined
try {
return await WorkspaceService.listUsableDatatableRoles({ workspace, datatableName: datatable })
return await WorkspaceService.listUsableDatatableRoles({
workspace,
datatableName: datatable
})
} catch (e) {
// Never leave the drawer waiting on this: fall back to the
// unpermissioned shape so it opens and the server picks the role.
@@ -88,12 +85,6 @@
uriState.selectedRole !== undefined))
)
const roleItems = $derived(
(usableRoles.current?.roles ?? []).map((r) => ({
value: r,
label: r === usableRoles.current?.default_role ? `${r} (default)` : r
}))
)
// Settle the role before anything queries the data table: the schema and
// metadata fetches run as whatever role the input carries, so leaving it unset
@@ -102,9 +93,7 @@
$effect(() => {
const roles = usableRoles.current
if (!roles?.enabled || uriState.selectedRole !== undefined) return
const effective = roles.roles.includes(roles.default_role)
? roles.default_role
: roles.roles[0]
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
if (effective) untrack(() => (uriState.selectedRole = effective))
})
@@ -238,6 +227,9 @@
bind:this={dbManagerContent}
input={uriState.effectiveInput}
workspace={uriState.workspace}
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
datatableTreeLoading={datatables.loading}
onSelectDatatable={(dt) => (uriState.selectedDatatable = dt)}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey={uriState.selectedSchema}
@@ -246,35 +238,6 @@
? (mode) => ((importDrawerOpen = true), (importBehavior = mode))
: undefined}
>
{#snippet dbSelector()}
{#if uriState.isDatatableInput}
{#if datatables.loading}
<div class="flex items-center gap-2 text-tertiary ml-2">
<LoaderCircle size={14} class="animate-spin" />
<span class="text-sm">Loading...</span>
</div>
{:else if datatables.current.length >= 1}
<Select
transformInputSelectedText={(s) => `Datatable: ${s}`}
items={datatableItems}
bind:value={uriState.selectedDatatable}
placeholder="Select data table"
size="md"
/>
{/if}
<!-- A single usable role is not a choice: the picker would only restate
what the connection already is. -->
{#if usableRoles.current?.enabled && roleItems.length > 1}
<Select
transformInputSelectedText={(s) => `Role: ${s}`}
items={roleItems}
bind:value={uriState.selectedRole}
placeholder="Role"
size="md"
/>
{/if}
{/if}
{/snippet}
</DBManagerContent>
{/key}
{/if}