From 4a20ac3ffd23f5612e270fa0555a0efc00ec324b Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 25 Aug 2026 14:21:17 +0200 Subject: [PATCH] feat(datatables): navigate the database manager with a data table tree --- frontend/src/lib/components/DBManager.svelte | 346 +++++++++++++----- .../lib/components/DBManagerContent.svelte | 15 +- .../src/lib/components/DBManagerDrawer.svelte | 65 +--- 3 files changed, 275 insertions(+), 151 deletions(-) diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 35bd88abec..4c02f02a86 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -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 { + // 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>(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 @@
- {#if dbSelector} + {#if multiSelectMode && dbSelector} {@render dbSelector()} {/if} - {#if dbSupportsSchemas && !multiSelectMode} -