feat: data table integrations for raw apps (#7436)

This commit is contained in:
Ruben Fiszel
2025-12-23 00:17:37 +00:00
committed by GitHub
parent b96bf83a45
commit d0343cc0e9
22 changed files with 2528 additions and 306 deletions
+409 -41
View File
@@ -15,6 +15,13 @@
import Portal from './Portal.svelte'
import Select from './select/Select.svelte'
import { safeSelectItems } from './select/utils.svelte'
import type { Snippet } from 'svelte'
/** Represents a selected table with its schema */
export interface SelectedTable {
schema: string
table: string
}
type Props = {
dbType: DbType
@@ -26,6 +33,15 @@
refresh?: () => void
initialSchemaKey?: string
initialTableKey?: string
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
dbSelector?: Snippet<[]>
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
selectedTables?: SelectedTable[]
/** Tables that are already added and should show as disabled */
disabledTables?: SelectedTable[]
}
let {
dbType,
@@ -36,9 +52,80 @@
dbSupportsSchemas,
refresh,
initialSchemaKey,
initialTableKey
initialTableKey,
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
dbSelector,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = []
}: Props = $props()
// Helper to check if a table is selected in multi-select mode
function isTableSelected(schema: string, table: string): boolean {
return selectedTables.some((t) => t.schema === schema && t.table === table)
}
// Helper to check if a table is disabled (already added)
function isTableDisabled(schema: string, table: string): boolean {
return disabledTables.some((t) => t.schema === schema && t.table === table)
}
// Toggle table selection in multi-select mode
function toggleTableSelection(schema: string, table: string) {
if (isTableDisabled(schema, table)) return
const idx = selectedTables.findIndex((t) => t.schema === schema && t.table === table)
if (idx >= 0) {
selectedTables = selectedTables.filter((_, i) => i !== idx)
} else {
selectedTables = [...selectedTables, { schema, table }]
}
}
// Get tables for a schema (filtered by search)
function getTablesForSchema(schema: string): string[] {
const tables = Object.keys(dbSchema.schema[schema] ?? {})
if (search) {
return tables.filter((t) => t.toLowerCase().includes(search.toLowerCase())).sort()
}
return tables.sort()
}
// Check if all selectable tables in a schema are selected
function isSchemaFullySelected(schema: string): boolean {
const tables = getTablesForSchema(schema)
if (tables.length === 0) return false
const selectableTables = tables.filter((t) => !isTableDisabled(schema, t))
if (selectableTables.length === 0) return true // All disabled means "fully selected"
return selectableTables.every((t) => isTableSelected(schema, t))
}
// Check if some (but not all) tables in a schema are selected
function isSchemaPartiallySelected(schema: string): boolean {
const tables = getTablesForSchema(schema)
const selectableTables = tables.filter((t) => !isTableDisabled(schema, t))
const selectedCount = selectableTables.filter((t) => isTableSelected(schema, t)).length
return selectedCount > 0 && selectedCount < selectableTables.length
}
// Toggle all tables in a schema
function toggleSchemaSelection(schema: string) {
const tables = getTablesForSchema(schema)
const selectableTables = tables.filter((t) => !isTableDisabled(schema, t))
if (isSchemaFullySelected(schema)) {
// Deselect all selectable tables in this schema
selectedTables = selectedTables.filter((t) => t.schema !== schema)
} else {
// Select all selectable tables in this schema
const newSelections = selectableTables
.filter((t) => !isTableSelected(schema, t))
.map((t) => ({ schema, table: t }))
selectedTables = [...selectedTables, ...newSelections]
}
}
let schemaKeys = $derived(Object.keys(dbSchema.schema ?? {}))
let search = $state('')
let selected: {
@@ -59,6 +146,16 @@
}
})
// Sync selected state with bindable props
$effect(() => {
if (selected.schemaKey) {
selectedSchemaKey = selected.schemaKey
}
if (selected.tableKey) {
selectedTableKey = selected.tableKey
}
})
let tableKeys = $derived.by(() => {
if (dbSchema.lang === 'graphql') {
sendUserToast('graphql not supported by DBExplorerTable', true)
@@ -91,12 +188,28 @@
| undefined = $state()
let dbTableEditorState: { open: boolean } = $state({ open: false })
let newSchemaDialogOpen = $state(false)
let newSchemaName = $state('')
// Check if the sanitized schema name already exists
const sanitizedNewSchemaName = $derived(
newSchemaName
.trim()
.toLowerCase()
.replace(/[^a-zA-Z0-9_]/g, '')
)
const schemaAlreadyExists = $derived(
sanitizedNewSchemaName !== '' && schemaKeys.includes(sanitizedNewSchemaName)
)
</script>
<Splitpanes>
<Pane size={24} class="relative flex flex-col">
<div class="mx-3 mt-3 flex flex-col gap-2">
{#if dbSupportsSchemas}
{#if dbSelector}
{@render dbSelector()}
{/if}
{#if dbSupportsSchemas && !multiSelectMode}
<Select
bind:value={selected.schemaKey}
items={safeSelectItems(schemaKeys)}
@@ -131,28 +244,139 @@
<ClearableInput bind:value={search} placeholder="Search table..." />
</div>
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
{#each filteredTableKeys as tableKey}
<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-gray-500/25' : 'hover:bg-gray-500/10')}
onclick={() => (selected.tableKey = tableKey)}
>
<Table2 class="text-primary shrink-0" size={16} />
<p class="truncate text-ellipsis grow text-left text-emphasis text-xs">{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',
{#if multiSelectMode}
<!-- Multi-select mode: show all schemas with their tables -->
{#if dbSupportsSchemas}
<!-- New schema button -->
<button
class="w-full text-sm font-medium flex gap-2 items-center h-9 cursor-pointer pl-3 pr-1 hover:bg-gray-500/10 border-b border-surface-secondary text-tertiary"
onclick={() => (newSchemaDialogOpen = true)}
>
<Plus class="shrink-0" size={14} />
<span class="text-xs">New schema</span>
</button>
{/if}
{#each schemaKeys as schemaKey}
{@const schemaTables = getTablesForSchema(schemaKey)}
{@const isFullySelected = isSchemaFullySelected(schemaKey)}
{@const isPartiallySelected = isSchemaPartiallySelected(schemaKey)}
{@const hasNoTables = schemaTables.length === 0}
<!-- Schema header with checkbox (or just label if empty) -->
<div
class="group w-full text-sm font-medium flex gap-2 items-center h-9 cursor-pointer pl-3 pr-1 hover:bg-gray-500/10 border-b border-surface-secondary"
role="button"
tabindex="0"
onclick={() => {
if (!hasNoTables) {
toggleSchemaSelection(schemaKey)
}
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!hasNoTables) {
toggleSchemaSelection(schemaKey)
}
}
}}
>
{#if hasNoTables}
<!-- Empty schema: no checkbox, just indent space -->
<span class="shrink-0 w-4"></span>
{:else}
<span class="shrink-0">
<input
type="checkbox"
checked={isFullySelected}
indeterminate={isPartiallySelected}
class="w-4 h-4 cursor-pointer"
onclick={(e) => e.stopPropagation()}
onchange={() => toggleSchemaSelection(schemaKey)}
/>
</span>
{/if}
<span class="truncate text-ellipsis grow text-left text-tertiary text-xs"
>{schemaKey}</span
>
<span class="text-2xs text-tertiary mr-2 group-hover:hidden">{schemaTables.length}</span>
<!-- Delete schema button (on hover) -->
<button
class="hidden group-hover:flex p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors mr-1"
title="Delete schema"
onclick={(e) => {
e.stopPropagation()
askingForConfirmation = {
title: `Are you sure you want to delete schema "${schemaKey}"? This will drop all tables in this schema. This action is irreversible.`,
confirmationText: 'Drop schema',
open: true,
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onDeleteSchema({ schema: schemaKey })
refresh?.()
sendUserToast(`Schema '${schemaKey}' deleted successfully`)
} catch (e) {
let msg: string | undefined = (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
askingForConfirmation = undefined
}
}
}}
>
<Trash2Icon size={12} class="text-red-500" />
</button>
</div>
<!-- Tables under this schema -->
{#each schemaTables as tableKey}
{@const isDisabled = isTableDisabled(schemaKey, tableKey)}
{@const isChecked = isTableSelected(schemaKey, tableKey) || isDisabled}
{@const isCurrentPreview = selected.schemaKey === schemaKey && selected.tableKey === tableKey}
<div
class={'group w-full text-sm font-normal flex gap-2 items-center h-8 cursor-pointer pl-7 pr-1 ' +
(isCurrentPreview ? 'bg-gray-500/25' : 'hover:bg-gray-500/10') +
(isDisabled ? ' opacity-50' : '')}
role="button"
tabindex="0"
onclick={() => {
selected.schemaKey = schemaKey
selected.tableKey = tableKey
toggleTableSelection(schemaKey, tableKey)
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
selected.schemaKey = schemaKey
selected.tableKey = tableKey
toggleTableSelection(schemaKey, tableKey)
}
}}
>
<span class="shrink-0">
<input
type="checkbox"
checked={isChecked}
disabled={isDisabled}
class="w-4 h-4 cursor-pointer"
onclick={(e) => e.stopPropagation()}
onchange={() => toggleTableSelection(schemaKey, tableKey)}
/>
</span>
<Table2 class="text-primary shrink-0" size={14} />
<p class="truncate text-ellipsis grow text-left text-emphasis text-xs">{tableKey}</p>
<!-- Delete table button (on hover) -->
<button
class="hidden group-hover:flex p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors mr-1"
title="Delete table"
onclick={(e) => {
e.stopPropagation()
askingForConfirmation = {
title: `Are you sure you want to delete table "${tableKey}"? This action is irreversible.`,
confirmationText: 'Drop table',
open: true,
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onDelete({ tableKey, schema: selected.schemaKey })
await dbSchemaOps.onDelete({ tableKey, schema: schemaKey })
refresh?.()
sendUserToast(`Table '${tableKey}' deleted successfully`)
} catch (e) {
@@ -162,29 +386,84 @@
}
askingForConfirmation = undefined
}
})
}
]}
class="w-fit"
}
}}
>
<Trash2Icon size={12} class="text-red-500" />
</button>
</div>
{/each}
<!-- New table button for this schema -->
<button
class="w-full text-sm font-normal flex gap-2 items-center h-8 cursor-pointer pl-7 pr-1 hover:bg-gray-500/10 text-tertiary"
onclick={() => {
selected.schemaKey = schemaKey
dbTableEditorState = { open: true }
}}
>
<svelte:fragment slot="buttonReplacement">
<MoreVertical
size={8}
class="w-8 h-8 p-2 hover:bg-surface-hover cursor-pointer rounded-md"
/>
</svelte:fragment>
</DropdownV2>
</button>
{/each}
<Plus class="shrink-0" size={14} />
<span class="text-xs">New table</span>
</button>
{/each}
{:else}
<!-- Normal mode: show tables for selected schema -->
{#each filteredTableKeys as tableKey}
<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-gray-500/25' : 'hover:bg-gray-500/10')}
onclick={() => (selected.tableKey = tableKey)}
>
<Table2 class="text-primary shrink-0" size={16} />
<p class="truncate text-ellipsis grow text-left text-emphasis text-xs">{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,
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 Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
askingForConfirmation = undefined
}
})
}
]}
class="w-fit"
>
<svelte:fragment slot="buttonReplacement">
<MoreVertical
size={8}
class="w-8 h-8 p-2 hover:bg-surface-hover cursor-pointer rounded-md"
/>
</svelte:fragment>
</DropdownV2>
</button>
{/each}
{/if}
</div>
<Button
on:click={() => (dbTableEditorState = { open: true })}
wrapperClasses="mx-2 my-2 text-sm"
startIcon={{ icon: Plus }}
variant={tableKeys.length === 0 ? 'accent' : 'default'}
>
New table
</Button>
{#if !multiSelectMode}
<Button
on:click={() => (dbTableEditorState = { open: true })}
wrapperClasses="mx-2 my-2 text-sm"
startIcon={{ icon: Plus }}
variant={tableKeys.length === 0 ? 'accent' : 'default'}
>
New table
</Button>
{/if}
</Pane>
<Pane class="p-3 pt-1">
{#if tableKey}
@@ -225,3 +504,92 @@
/>
</DrawerContent>
</Drawer>
<Drawer
size="400px"
open={newSchemaDialogOpen}
on:close={() => {
newSchemaDialogOpen = false
newSchemaName = ''
}}
>
<DrawerContent
on:close={() => {
newSchemaDialogOpen = false
newSchemaName = ''
}}
title="Create a new schema"
>
<div class="flex flex-col gap-4">
<div>
<label for="schema-name" class="block text-sm font-medium text-primary mb-1"
>Schema name</label
>
<ClearableInput
bind:value={newSchemaName}
placeholder="Enter schema name..."
autofocus
on:keydown={(e) => {
if (e.key === 'Enter' && sanitizedNewSchemaName && !schemaAlreadyExists) {
askingForConfirmation = {
confirmationText: `Create ${sanitizedNewSchemaName}`,
type: 'reload',
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
open: true,
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onCreateSchema({ schema: sanitizedNewSchemaName })
refresh?.()
selected.schemaKey = sanitizedNewSchemaName
newSchemaDialogOpen = false
newSchemaName = ''
} finally {
askingForConfirmation = undefined
}
}
}
}
}}
/>
{#if schemaAlreadyExists}
<p class="text-xs text-red-500 mt-1">
Schema "{sanitizedNewSchemaName}" already exists
</p>
{:else}
<p class="text-xs text-tertiary mt-1">
Only letters, numbers, and underscores are allowed.
</p>
{/if}
</div>
</div>
{#snippet actions()}
<Button
color="blue"
disabled={!sanitizedNewSchemaName || schemaAlreadyExists}
on:click={() => {
askingForConfirmation = {
confirmationText: `Create ${sanitizedNewSchemaName}`,
type: 'reload',
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
open: true,
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onCreateSchema({ schema: sanitizedNewSchemaName })
refresh?.()
selected.schemaKey = sanitizedNewSchemaName
newSchemaDialogOpen = false
newSchemaName = ''
} finally {
askingForConfirmation = undefined
}
}
}
}}
>
Create schema
</Button>
{/snippet}
</DrawerContent>
</Drawer>
@@ -0,0 +1,248 @@
<script lang="ts">
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import { sendUserToast, sortArray } from '$lib/utils'
import { Loader2 } from 'lucide-svelte'
import { dbSupportsSchemas, type TableMetadata } from './apps/components/display/dbtable/utils'
import DbManager from './DBManager.svelte'
import {
dbSchemaOpsWithPreviewScripts,
dbTableOpsWithPreviewScripts,
getDbType,
getDucklakeSchema
} from './dbOps'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import { untrack, type Snippet } from 'svelte'
import type { DbInput } from './dbTypes'
import {
getDbSchemas,
loadAllTablesMetaData,
loadTableMetaData
} from './apps/components/display/dbtable/metadata'
import type { SelectedTable } from './DBManager.svelte'
interface Props {
input: DbInput
showRepl?: boolean
hasReplResult?: boolean
isRefreshing?: boolean
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
dbSelector?: Snippet<[]>
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
selectedTables?: SelectedTable[]
/** Tables that are already added and should show as disabled */
disabledTables?: SelectedTable[]
}
let {
input,
showRepl = true,
hasReplResult = $bindable(false),
isRefreshing = $bindable(false),
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
dbSelector,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = []
}: Props = $props()
let dbSchema: DBSchema | undefined = $derived($dbSchemas[getDbSchemasPath(input)])
function getDbSchemasPath(input: DbInput): string {
switch (input.type) {
case 'database':
return input.resourcePath
case 'ducklake':
return 'ducklake://' + input.ducklake
}
}
// `refreshCount` is a derived state. `refreshing` is the source of truth
let refreshCount = $state(0)
$effect(() => {
if (refreshing) untrack(() => (refreshCount += 1))
})
let refreshing = $state(false)
$effect(() => {
if (refreshing) getSchema()
})
// Sync refreshing state with bindable prop
$effect(() => {
isRefreshing = refreshing
})
export const refresh = () => !refreshing && (refreshing = true)
// Initial schema load
$effect(() => {
if (input) {
untrack(() => getSchema())
}
})
async function getSchema() {
if (!input) return
const dbSchemasPath = getDbSchemasPath(input)
if ($dbSchemas[dbSchemasPath] && !refreshing) return
const oldDbSchema = $dbSchemas[dbSchemasPath]
if (input.type == 'database') {
await getDbSchemas(
input.resourceType,
input.resourcePath,
$workspaceStore,
$dbSchemas,
(message: string) => {
sendUserToast(message, true)
}
)
} else if (input.type == 'ducklake') {
$dbSchemas[dbSchemasPath] = await getDucklakeSchema({
workspace: $workspaceStore!,
ducklake: input.ducklake
})
}
// avoid infinite loop on error due to the way getDbSchemas is implemented
// and relying on an assignement side effect
if (oldDbSchema !== $dbSchemas[dbSchemasPath]) $dbSchemas = $dbSchemas
refreshing = false
}
let replPanelSize = $state(36)
const REPL_MIN_SIZE = 1.5
let replResultData: undefined | Record<string, any>[] = $state(undefined)
// Sync replResultData state with bindable prop
$effect(() => {
hasReplResult = !!replResultData
})
let cachedColDefs: Record<string, TableMetadata> = {}
let cachedLastRefreshCount = 0
async function getColDefs(tableKey: string): Promise<TableMetadata> {
if (cachedLastRefreshCount !== refreshCount) cachedColDefs = {}
cachedLastRefreshCount = refreshCount
if (cachedColDefs[tableKey]) return cachedColDefs[tableKey]
if (!input) return []
try {
cachedColDefs = (await loadAllTablesMetaData($workspaceStore, input)) ?? cachedColDefs
return cachedColDefs[tableKey]
} catch (e) {
if (input?.type == 'ducklake')
throw 'Impossible that loadAllTablesMetaData fails for Ducklake'
// Query is not implemented for all dbs, need a fallback
const result = await loadTableMetaData(input, $workspaceStore, tableKey)
if (result) cachedColDefs[tableKey] = result
return result ?? []
}
}
// Export for parent components
export function clearReplResult() {
replResultData = undefined
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') {
if (replResultData) {
replResultData = undefined
}
}
}}
/>
{#if dbSchema && $workspaceStore && input}
{@const _input = input}
{@const dbType = getDbType(_input)}
<Splitpanes horizontal>
<Pane class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={'absolute inset-0 z-10 p-8 ' +
(replResultData
? 'bg-surface/90'
: 'transition-colors bg-transparent pointer-events-none select-none')}
onclick={(e) => {
// Only proceed if the click is directly on this div and not on the child elements
if (e.target === e.currentTarget) {
replResultData = undefined
}
}}
>
{#if replResultData}
{#key replResultData}
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
{/key}
{/if}
</div>
<DbManager
dbSupportsSchemas={input.type == 'database' && dbSupportsSchemas(input.resourceType)}
{dbSchema}
{getColDefs}
dbTableOpsFactory={({ colDefs, tableKey }) =>
dbTableOpsWithPreviewScripts({
colDefs,
tableKey,
input: _input,
workspace: $workspaceStore
})}
dbSchemaOps={dbSchemaOpsWithPreviewScripts({
input: _input,
workspace: $workspaceStore
})}
initialTableKey={input.specificTable}
initialSchemaKey={input.type == 'database' ? input.specificSchema : undefined}
{dbType}
refresh={() => refresh()}
{dbSelector}
bind:selectedSchemaKey
bind:selectedTableKey
{multiSelectMode}
bind:selectedTables
{disabledTables}
/>
</Pane>
{#if showRepl}
<Pane bind:size={replPanelSize} minSize={REPL_MIN_SIZE} class="relative">
<SqlRepl
{input}
onData={(data) => {
replResultData = data
}}
placeholderTableName={sortArray(
Object.keys(
dbSchema?.schema[
'public' in dbSchema?.schema
? 'public'
: 'dbo' in dbSchema?.schema
? 'dbo'
: Object.keys(dbSchema?.schema ?? {})?.[0]
] ?? {}
)
)?.[0]}
/>
</Pane>
{/if}
</Splitpanes>
{:else}
<Splitpanes>
<Pane class="relative flex justify-center items-center">
<Loader2 class="animate-spin" size={32} />
</Pane>
</Splitpanes>
{/if}
+101 -190
View File
@@ -1,236 +1,147 @@
<script lang="ts">
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { sendUserToast, sortArray } from '$lib/utils'
import { ArrowLeft, Expand, Loader2, Minimize, RefreshCcw } from 'lucide-svelte'
import { dbSupportsSchemas, type TableMetadata } from './apps/components/display/dbtable/utils'
import DbManager from './DBManager.svelte'
import {
dbSchemaOpsWithPreviewScripts,
dbTableOpsWithPreviewScripts,
getDbType,
getDucklakeSchema
} from './dbOps'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import { untrack } from 'svelte'
import Select from './select/Select.svelte'
import { ArrowLeft, Expand, LoaderCircle, Minimize, RefreshCcw } from 'lucide-svelte'
import type { DbInput } from './dbTypes'
import {
getDbSchemas,
loadAllTablesMetaData,
loadTableMetaData
} from './apps/components/display/dbtable/metadata'
import DBManagerContent from './DBManagerContent.svelte'
import { resource } from 'runed'
interface Props {
/** Z-index offset for the drawer, useful when opening from within modals */
offset?: number
}
let { offset = 0 }: Props = $props()
let input: DbInput | undefined = $state()
let open = $derived(!!input)
// For datatable inputs, track the selected datatable separately
let selectedDatatable = $state<string | undefined>(undefined)
// Check if input is a datatable type
const isDatatableInput = $derived(
input?.type === 'database' && input.resourcePath.startsWith('datatable://')
)
// Load available datatables when drawer opens with datatable input
const datatables = resource<string[]>([], async () => {
if (!$workspaceStore) return []
try {
return await WorkspaceService.listDataTables({ workspace: $workspaceStore })
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
})
// Computed input that updates when selectedDatatable changes
const effectiveInput: DbInput | undefined = $derived.by(() => {
if (!input) return undefined
if (!isDatatableInput || !selectedDatatable) return input
return {
...input,
resourcePath: `datatable://${selectedDatatable}`
}
})
const datatableItems = $derived(
datatables.current.map((dt) => ({
value: dt,
label: dt
}))
)
export function openDrawer(nInput: DbInput) {
input = nInput
getSchema()
if (isDatatableInput) {
datatables.refetch()
}
// If it's a datatable input, extract the datatable name for the selector
if (nInput.type === 'database' && nInput.resourcePath.startsWith('datatable://')) {
selectedDatatable = nInput.resourcePath.replace('datatable://', '')
datatables.refetch()
} else {
selectedDatatable = undefined
}
}
export function closeDrawer() {
input = undefined
refreshCount = 0
refreshing = false
selectedDatatable = undefined
dbManagerContent?.clearReplResult()
}
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[getDbSchemasPath(input)])
function getDbSchemasPath(input: DbInput): string {
switch (input.type) {
case 'database':
return input.resourcePath
case 'ducklake':
return 'ducklake://' + input.ducklake
}
}
// `refreshCount` is a derived state. `refreshing` is the source of truth
let refreshCount = $state(0)
$effect(() => {
if (refreshing) untrack(() => (refreshCount += 1))
})
let refreshing = $state(false)
$effect(() => {
if (refreshing) getSchema()
})
const refresh = () => !refreshing && (refreshing = true)
let windowWidth = $state(window.innerWidth)
let expand = $state(false)
$effect(() => {
if (!open) expand = false
})
async function getSchema() {
if (!input) return
const dbSchemasPath = getDbSchemasPath(input)
if ($dbSchemas[dbSchemasPath] && !refreshing) return
let dbManagerContent: DBManagerContent | undefined = $state()
const oldDbSchema = $dbSchemas[dbSchemasPath]
if (input.type == 'database') {
await getDbSchemas(
input.resourceType,
input.resourcePath,
$workspaceStore,
$dbSchemas,
(message: string) => {
if (open) sendUserToast(message, true)
}
)
} else if (input.type == 'ducklake') {
$dbSchemas[dbSchemasPath] = await getDucklakeSchema({
workspace: $workspaceStore!,
ducklake: input.ducklake
})
}
// avoid infinite loop on error due to the way getDbSchemas is implemented
// and relying on an assignement side effect
if (oldDbSchema !== $dbSchemas[dbSchemasPath]) $dbSchemas = $dbSchemas
refreshing = false
}
let windowWidth = $state(window.innerWidth)
let replPanelSize = $state(36)
const REPL_MIN_SIZE = 1.5
let replResultData: undefined | Record<string, any>[] = $state(undefined)
let cachedColDefs: Record<string, TableMetadata> = {}
let cachedLastRefreshCount = 0
async function getColDefs(tableKey: string): Promise<TableMetadata> {
if (cachedLastRefreshCount !== refreshCount) cachedColDefs = {}
cachedLastRefreshCount = refreshCount
if (cachedColDefs[tableKey]) return cachedColDefs[tableKey]
if (!input) return []
try {
cachedColDefs = (await loadAllTablesMetaData($workspaceStore, input)) ?? cachedColDefs
return cachedColDefs[tableKey]
} catch (e) {
if (input?.type == 'ducklake')
throw 'Impossible that loadAllTablesMetaData fails for Ducklake'
// Query is not implemented for all dbs, need a fallback
const result = await loadTableMetaData(input, $workspaceStore, tableKey)
if (result) cachedColDefs[tableKey] = result
return result ?? []
}
}
let hasReplResult = $state(false)
let isRefreshing = $state(false)
</script>
<svelte:window
bind:innerWidth={windowWidth}
onkeydown={(e) => {
if (e.key === 'Escape') {
if (replResultData) {
replResultData = undefined
}
}
}}
/>
<svelte:window bind:innerWidth={windowWidth} />
<Drawer
bind:open
size={expand ? `${windowWidth}px` : '1200px'}
preventEscape
{offset}
on:close={closeDrawer}
>
<DrawerContent
title={replResultData ? 'Query Result' : 'Database Manager'}
title={hasReplResult ? 'Query Result' : 'Database Manager'}
on:close={() => {
if (replResultData) {
replResultData = undefined
if (hasReplResult) {
dbManagerContent?.clearReplResult()
} else {
closeDrawer()
}
}}
CloseIcon={replResultData ? ArrowLeft : undefined}
CloseIcon={hasReplResult ? ArrowLeft : undefined}
noPadding
>
{#if dbSchema && $workspaceStore && input}
{@const _input = input}
{@const dbType = getDbType(_input)}
<Splitpanes horizontal>
<Pane class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={'absolute inset-0 z-10 p-8 ' +
(replResultData
? 'bg-surface/90'
: 'transition-colors bg-transparent pointer-events-none select-none')}
onclick={(e) => {
// Only proceed if the click is directly on this div and not on the child elements
if (e.target === e.currentTarget) {
replResultData = undefined
}
}}
>
{#if replResultData}
{#key replResultData}
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
{/key}
{#if effectiveInput && $workspaceStore}
{#key selectedDatatable}
<DBManagerContent
bind:this={dbManagerContent}
input={effectiveInput}
bind:hasReplResult
bind:isRefreshing
>
{#snippet dbSelector()}
{#if 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={selectedDatatable}
placeholder="Select data table"
size="md"
/>
{/if}
{/if}
</div>
<DbManager
dbSupportsSchemas={input.type == 'database' && dbSupportsSchemas(input.resourceType)}
{dbSchema}
{getColDefs}
dbTableOpsFactory={({ colDefs, tableKey }) =>
dbTableOpsWithPreviewScripts({
colDefs,
tableKey,
input: _input,
workspace: $workspaceStore
})}
dbSchemaOps={dbSchemaOpsWithPreviewScripts({
input: _input,
workspace: $workspaceStore
})}
initialTableKey={input.specificTable}
initialSchemaKey={input.type == 'database' ? input.specificSchema : undefined}
{dbType}
{refresh}
/>
</Pane>
<Pane bind:size={replPanelSize} minSize={REPL_MIN_SIZE} class="relative">
<SqlRepl
{input}
onData={(data) => {
replResultData = data
}}
placeholderTableName={sortArray(
Object.keys(
dbSchema?.schema[
'public' in dbSchema?.schema
? 'public'
: 'dbo' in dbSchema?.schema
? 'dbo'
: Object.keys(dbSchema?.schema ?? {})?.[0]
] ?? {}
)
)?.[0]}
/>
</Pane>
</Splitpanes>
{:else}
<Splitpanes>
<Pane class="relative flex justify-center items-center">
<Loader2 class="animate-spin" size={32} />
</Pane>
</Splitpanes>
{/snippet}
</DBManagerContent>
{/key}
{/if}
{#snippet actions()}
<Button
loading={refreshing}
on:click={() => refresh()}
loading={isRefreshing}
on:click={() => dbManagerContent?.refresh()}
startIcon={{ icon: RefreshCcw }}
size="xs"
color="light"
@@ -20,6 +20,7 @@
fullScreen?: boolean
eeOnly?: boolean
actions?: import('svelte').Snippet
titleExtra?: import('svelte').Snippet
children?: import('svelte').Snippet
}
@@ -36,6 +37,7 @@
fullScreen = true,
eeOnly = false,
actions,
titleExtra,
children
}: Props = $props()
@@ -65,6 +67,9 @@
{#if eeOnly && !$enterpriseLicense}
<EEOnly />
{/if}
{#if titleExtra}
{@render titleExtra()}
{/if}
</div>
{#if actions}
<div class="flex gap-2 items-center justify-end shrink-0">
@@ -9,6 +9,7 @@
import ChatQuickActions from './ChatQuickActions.svelte'
import ProviderModelSelector from './ProviderModelSelector.svelte'
import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Markdown from 'svelte-exmarkdown'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -259,8 +260,9 @@
<Markdown md={disabledMessage} />
</div>
{:else}
<div class="flex flex-row gap-2 min-w-0">
<div class="flex flex-row gap-2 min-w-0 flex-wrap items-center">
<ChatMode />
<DatatableCreationPolicy />
<ProviderModelSelector />
</div>
{/if}
@@ -99,6 +99,12 @@ class AIChatManager {
scriptEditorShowDiffMode = $state<(() => void) | undefined>(undefined)
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
appAiChatHelpers = $state<AppAIChatHelpers | undefined>(undefined)
/** Datatable creation policy: enabled flag, datatable name, and optional schema */
datatableCreationPolicy = $state<{
enabled: boolean
datatable: string | undefined
schema: string | undefined
}>({ enabled: false, datatable: undefined, schema: undefined })
pendingNewCode = $state<string | undefined>(undefined)
apiTools = $state<Tool<any>[]>([])
aiChatInput = $state<AIChatInput | null>(null)
@@ -0,0 +1,73 @@
<script lang="ts">
import { AlertTriangle } from 'lucide-svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import DefaultDatabaseSelector from '$lib/components/raw_apps/DefaultDatabaseSelector.svelte'
import { workspaceStore } from '$lib/stores'
import { createDatatablesResource } from '$lib/components/raw_apps/datatableUtils.svelte'
// Load available datatables from workspace using shared utility
const datatables = createDatatablesResource(() => $workspaceStore)
const hasNoDatatables = $derived((datatables.current?.length ?? 0) === 0)
// Auto-select first datatable when datatables load and none is selected
$effect(() => {
if (
datatables.current.length > 0 &&
aiChatManager.datatableCreationPolicy.enabled &&
!aiChatManager.datatableCreationPolicy.datatable
) {
aiChatManager.datatableCreationPolicy.datatable = datatables.current[0]
}
})
function handleToggle(enabled: boolean) {
aiChatManager.datatableCreationPolicy.enabled = enabled
if (
enabled &&
datatables.current.length > 0 &&
!aiChatManager.datatableCreationPolicy.datatable
) {
aiChatManager.datatableCreationPolicy.datatable = datatables.current[0]
}
}
function handleDefaultChange(datatable: string | undefined, schema: string | undefined) {
aiChatManager.datatableCreationPolicy.datatable = datatable
aiChatManager.datatableCreationPolicy.schema = schema
}
</script>
{#if aiChatManager.mode === AIMode.APP}
<div class="min-w-0 flex items-center gap-1 pt-0.5">
{#if hasNoDatatables}
<!-- Warning when no datatables are available -->
<div
class="text-2xs flex flex-row items-center gap-1 text-red-600 dark:text-red-400 px-1"
title="No datatables configured. Add datatables in the Data panel so AI can create tables."
>
<AlertTriangle size={12} class="shrink-0" />
<span class="truncate">No datatables</span>
</div>
{:else}
<!-- Toggle for new tables -->
<div class="flex items-center gap-1">
<Toggle
size="xs"
checked={aiChatManager.datatableCreationPolicy.enabled}
on:change={(e) => handleToggle(e.detail)}
/>
<span class="text-2xs text-secondary whitespace-nowrap">tables creation</span>
</div>
<!-- Settings icon with popover -->
<DefaultDatabaseSelector
datatable={aiChatManager.datatableCreationPolicy.datatable}
schema={aiChatManager.datatableCreationPolicy.schema}
onChange={handleDefaultChange}
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
/>
{/if}
</div>
{/if}
@@ -104,6 +104,26 @@ export function createAppEvalHelpers(
lint: () => {
// Return mock lint result - no actual linting in eval
return createEmptyLintResult()
},
// Data table operations (mock implementation for testing)
getDatatables: async () => {
// Return empty array for eval testing - no real datatables in test context
return []
},
getAvailableDatatableNames: () => {
// Return empty array for eval testing - no real datatables in test context
return []
},
execDatatableSql: async (
_datatableName: string,
_sql: string,
_newTable?: { schema: string; name: string }
) => {
// Return success with empty result for eval testing
return { success: true, result: [] }
}
}
@@ -7,6 +7,7 @@ import { createSearchHubScriptsTool, createToolDef, type Tool } from '../shared'
import { FlowService, ScriptService, type Flow, type Script } from '$lib/gen'
import uFuzzy from '@leeoniya/ufuzzy'
import { emptyString } from '$lib/utils'
import { aiChatManager } from '../AIChatManager.svelte'
// Backend runnable types
export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline'
@@ -62,6 +63,19 @@ export interface SelectedContext {
// textSelection?: { startLine: number; endLine: number; startColumn: number; endColumn: number }
}
/** Schema for a table in a datatable */
export interface DataTableTableSchema {
schema: string
table: string
columns: Record<string, { type: string; required: boolean }>
}
/** Full datatable info including schema */
export interface DataTableInfo {
name: string
tables: DataTableTableSchema[]
}
export interface AppAIChatHelpers {
// Frontend file operations
listFrontendFiles: () => string[]
@@ -85,6 +99,17 @@ export interface AppAIChatHelpers {
// Linting
/** Lint all frontend files and backend runnables, returns errors and warnings */
lint: () => LintResult
// Data table operations
/** Get all datatables configured in the app with their schemas */
getDatatables: () => Promise<DataTableInfo[]>
/** Get unique datatable names configured in the app (for UI policy selector) */
getAvailableDatatableNames: () => string[]
/** Execute a SQL query on a datatable. Optionally specify newTable to register a newly created table. */
execDatatableSql: (
datatableName: string,
sql: string,
newTable?: { schema: string; name: string }
) => Promise<{ success: boolean; result?: Record<string, any>[]; error?: string }>
}
// ============= Utility =============
@@ -244,6 +269,49 @@ const getGetFilesToolDef = memo(() =>
)
)
// ============= Data Table Tools =============
const getGetDatatablesSchema = memo(() => z.object({}))
const getGetDatatablesToolDef = memo(() =>
createToolDef(
getGetDatatablesSchema(),
'get_datatables',
'Get all datatables configured in this app with their full schemas. Returns datatable names, tables, and column definitions. Use this to understand the data layer available to the app.'
)
)
const getExecDatatableSqlSchema = memo(() =>
z.object({
datatable_name: z
.string()
.describe(
'The name of the datatable to query (e.g., "main"). Must be one of the datatables configured in the app.'
),
sql: z
.string()
.describe(
'The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, etc. For SELECT queries, results are returned as an array of objects.'
),
new_table: z
.object({
schema: z.string().describe('The schema name where the table was created (e.g., "public")'),
name: z.string().describe('The name of the newly created table')
})
.optional()
.describe(
'When executing a CREATE TABLE statement, provide this to register the new table in the app so it can be queried and its schema retrieved later.'
)
})
)
const getExecDatatableSqlToolDef = memo(() =>
createToolDef(
getExecDatatableSqlSchema(),
'exec_datatable_sql',
'Execute a SQL query on a datatable. Use this to explore data, test queries, create tables, or make changes. When creating a new table, pass new_table to register it in the app for future use.',
{ strict: false }
)
)
// ============= Selected Context Tool =============
const getGetSelectedContextSchema = memo(() => z.object({}))
@@ -649,7 +717,105 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [
}
},
// Hub scripts search (reuse from shared)
createSearchHubScriptsTool(false)
createSearchHubScriptsTool(false),
// Data table tools
{
def: getGetDatatablesToolDef(),
fn: async ({ helpers, toolId, toolCallbacks }) => {
toolCallbacks.setToolStatus(toolId, { content: 'Getting datatables...' })
try {
const datatables = await helpers.getDatatables()
if (datatables.length === 0) {
toolCallbacks.setToolStatus(toolId, { content: 'No datatables configured' })
return 'No datatables are configured in this app. Use the Data panel in the sidebar to add datatable references.'
}
const totalTables = datatables.reduce((acc, dt) => acc + dt.tables.length, 0)
toolCallbacks.setToolStatus(toolId, {
content: `Found ${datatables.length} datatable(s) with ${totalTables} table(s)`
})
return JSON.stringify(datatables, null, 2)
} catch (e) {
const errorMsg = `Error getting datatables: ${e instanceof Error ? e.message : String(e)}`
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
return errorMsg
}
}
},
{
def: getExecDatatableSqlToolDef(),
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
const parsedArgs = getExecDatatableSqlSchema().parse(args)
// Enforce datatable creation policy when new_table is specified
if (parsedArgs.new_table) {
const policy = aiChatManager.datatableCreationPolicy
if (!policy.enabled) {
const errorMsg =
'Table creation is not allowed. The user has disabled the "New tables" option.'
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
return JSON.stringify({ success: false, error: errorMsg })
}
if (policy.datatable && policy.datatable !== parsedArgs.datatable_name) {
const errorMsg = `Table creation is only allowed on datatable "${policy.datatable}", but you tried to create on "${parsedArgs.datatable_name}".`
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
return JSON.stringify({ success: false, error: errorMsg })
}
if (policy.schema && policy.schema !== parsedArgs.new_table.schema) {
const errorMsg = `Table creation is only allowed in schema "${policy.schema}", but you tried to create in "${parsedArgs.new_table.schema}".`
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
return JSON.stringify({ success: false, error: errorMsg })
}
}
toolCallbacks.setToolStatus(toolId, {
content: `Executing SQL on "${parsedArgs.datatable_name}"...`
})
try {
const result = await helpers.execDatatableSql(
parsedArgs.datatable_name,
parsedArgs.sql,
parsedArgs.new_table
)
if (result.success) {
let successMessage = 'Query executed successfully'
if (parsedArgs.new_table) {
successMessage = `Table "${parsedArgs.new_table.schema}.${parsedArgs.new_table.name}" created and registered`
}
if (result.result) {
const rowCount = result.result.length
toolCallbacks.setToolStatus(toolId, {
content: `Query returned ${rowCount} row(s)`
})
// Truncate large results
const MAX_ROWS = 100
if (rowCount > MAX_ROWS) {
return JSON.stringify(
{
success: true,
rowCount,
result: result.result.slice(0, MAX_ROWS),
note: `Showing first ${MAX_ROWS} of ${rowCount} rows`
},
null,
2
)
}
return JSON.stringify({ success: true, rowCount, result: result.result }, null, 2)
}
toolCallbacks.setToolStatus(toolId, { content: successMessage })
return JSON.stringify({ success: true, message: successMessage })
} else {
const errorMsg = result.error || 'Unknown error'
toolCallbacks.setToolStatus(toolId, { content: `Error: ${errorMsg}`, error: errorMsg })
return JSON.stringify({ success: false, error: errorMsg })
}
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e)
toolCallbacks.setToolStatus(toolId, { content: `Error: ${errorMsg}`, error: errorMsg })
return JSON.stringify({ success: false, error: errorMsg })
}
}
}
])
export function prepareAppSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam {
@@ -691,6 +857,80 @@ For inline scripts, the code must have a \`main\` function as its entrypoint.
- \`list_workspace_runnables(query, type?)\`: Search workspace scripts and flows
- \`search_hub_scripts(query)\`: Search hub scripts
### Data Tables
- \`get_datatables()\`: Get all datatables configured in the app with their schemas (tables, columns, types)
- \`exec_datatable_sql(datatable_name, sql, new_table?)\`: Execute SQL query on a datatable. Use for data exploration or modifications. When creating a new table, pass \`new_table: { schema, name }\` to register it in the app.
## Data Storage with Data Tables
**When the app needs to store or persist data, you MUST use datatables.** Datatables provide a managed PostgreSQL database that integrates seamlessly with Windmill apps, with near-zero setup and workspace-scoped access.
### Key Principles
1. **Always check existing tables first**: Use \`get_datatables()\` to see what tables are already available. If a suitable table exists, **always reuse it** rather than creating a new one.
2. **CRITICAL: Create tables ONLY via exec_datatable_sql tool**: When you need to create a new table, you MUST use the \`exec_datatable_sql\` tool with the \`new_table\` parameter. **NEVER** create tables inside backend runnables using SQL queries - this will not register the table properly and it won't be available for future use.
\`\`\`
exec_datatable_sql({
datatable_name: "main",
sql: "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE, created_at TIMESTAMP DEFAULT NOW())",
new_table: { schema: "public", name: "users" }
})
\`\`\`
3. **Use schemas to organize data**: Use PostgreSQL schemas to organize tables logically. Reference schemas with \`schema.table\` syntax.
4. **Use datatables for**:
- User data, settings, preferences
- Application state that needs to persist
- Lists, records, logs, history
- Any data the app needs to store and retrieve
### Accessing Data Tables from Backend Runnables
Backend runnables should only perform **data operations** (SELECT, INSERT, UPDATE, DELETE) on **existing tables**. Never use CREATE TABLE, DROP TABLE, or ALTER TABLE inside runnables.
**TypeScript (Bun)**:
\`\`\`typescript
import * as wmill from 'windmill-client';
export async function main(user_id: string) {
// Use default 'main' datatable
let sql = wmill.datatable();
// Or specify a named datatable: wmill.datatable('named_datatable')
// Safe string interpolation (parameterized query)
let user = await sql\`SELECT * FROM users WHERE id = \${user_id}\`.fetchOne();
return user;
}
\`\`\`
**Python**:
\`\`\`python
import wmill
def main(user_id: str):
db = wmill.datatable() # or wmill.datatable('named_datatable')
# Use positional arguments ($1, $2, etc.)
user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()
return user
\`\`\`
### Common Operations (for use in backend runnables)
- **Fetch all**: \`sql\`SELECT * FROM table\`.fetch()\` or \`db.query('SELECT * FROM table').fetch()\`
- **Fetch one**: \`.fetchOne()\` or \`.fetch_one()\`
- **Insert**: \`sql\`INSERT INTO table (col) VALUES (\${value})\`\`
- **Update**: \`sql\`UPDATE table SET col = \${value} WHERE id = \${id}\`\`
- **Delete**: \`sql\`DELETE FROM table WHERE id = \${id}\`\`
The "main" datatable is the default and can be accessed without specifying a name.
### Schema Modifications (DDL) - Use exec_datatable_sql tool ONLY
For any schema changes (CREATE TABLE, DROP TABLE, ALTER TABLE, CREATE INDEX, etc.), you MUST use the \`exec_datatable_sql\` tool directly. This ensures tables are properly registered in the app.
## Backend Runnable Configuration
When creating a backend runnable with \`set_backend_runnable\`:
@@ -769,8 +1009,29 @@ When creating a new app, use \`list_workspace_runnables\` or \`search_hub_script
`
// Add datatable creation policy context
const policy = aiChatManager.datatableCreationPolicy
if (policy.enabled && policy.datatable) {
const schemaPrefix = policy.schema ? `${policy.schema}.` : ''
content += `## Datatable Creation Policy
**Table creation is ENABLED.** You can create new tables using \`exec_datatable_sql\` with the \`new_table\` parameter.
- **Default datatable**: ${policy.datatable}${policy.schema ? `\n- **Default schema**: ${policy.schema}` : ''}
When creating new tables, you MUST use the default datatable${policy.schema ? ` and schema` : ''} specified above. Do not create tables in other datatables or schemas.
${policy.schema ? `\n**IMPORTANT**: Always use the schema prefix \`${schemaPrefix}\` in your SQL queries when creating or referencing tables. For example: \`CREATE TABLE ${schemaPrefix}my_table (...)\` and \`SELECT * FROM ${schemaPrefix}my_table\`. Never create tables without the schema prefix as they would go to the public schema instead.` : ''}
`
} else {
content += `## Datatable Creation Policy
**Table creation is DISABLED.** You must NOT create new datatable tables. If you need to create a table to complete the task, inform the user that table creation is disabled and ask them to enable it in the Data panel settings.
`
}
if (customPrompt?.trim()) {
content = `${content}\n\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
content = `${content}\nUSER GIVEN INSTRUCTIONS:\n${customPrompt.trim()}`
}
return {
+11 -1
View File
@@ -116,6 +116,7 @@ export type IDbSchemaOps = {
onCreate: (params: { values: CreateTableValues; schema?: string }) => Promise<void>
previewCreateSql: (params: { values: CreateTableValues; schema?: string }) => string
onCreateSchema: (params: { schema: string }) => Promise<void>
onDeleteSchema: (params: { schema: string }) => Promise<void>
}
export function dbSchemaOpsWithPreviewScripts({
@@ -154,6 +155,15 @@ export function dbSchemaOpsWithPreviewScripts({
workspace,
requestBody: { args: { ...dbArg }, language, content: createSchemaQuery }
})
},
onDeleteSchema: async ({ schema }) => {
let dropSchemaQuery = `DROP SCHEMA ${schema} CASCADE;`
if (input.type === 'ducklake')
dropSchemaQuery = wrapDucklakeQuery(dropSchemaQuery, input.ducklake)
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg }, language, content: dropSchemaQuery }
})
}
}
}
@@ -216,7 +226,7 @@ export function getDbType(input: DbInput): DbType {
export function getDatabaseArg(input: DbInput | undefined) {
if (input?.type === 'database') {
if (input.resourcePath.startsWith('datatable://')) {
return { database: 'datatable://' + input.resourcePath }
return { database: input.resourcePath }
} else {
return { database: '$res:' + input.resourcePath }
}
@@ -0,0 +1,87 @@
<script lang="ts">
import { Settings } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Select from '$lib/components/select/Select.svelte'
import { workspaceStore } from '$lib/stores'
import {
createDatatablesResource,
createSchemasResource,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
interface Props {
/** Currently selected datatable */
datatable: string | undefined
/** Currently selected schema */
schema: string | undefined
/** Callback when either value changes */
onChange?: (datatable: string | undefined, schema: string | undefined) => void
/** Description text to show in the popover */
description?: string
}
let {
datatable,
schema,
onChange,
description = 'Set the default datatable and schema for new tables. This is where AI will create new tables when needed.'
}: Props = $props()
// Load available datatables and schemas using shared utilities
const datatables = createDatatablesResource(() => $workspaceStore)
const schemas = createSchemasResource(() => datatable)
const datatableItems = $derived(toDatatableItems(datatables.current))
const schemaItems = $derived(toSchemaItems(schemas.current))
// Track datatable changes to reset schema
let previousDatatable = $state<string | undefined>(undefined)
$effect(() => {
if (previousDatatable !== undefined && datatable !== previousDatatable) {
// Reset schema when datatable changes
onChange?.(datatable, undefined)
}
previousDatatable = datatable
})
</script>
<Popover>
<svelte:fragment slot="trigger">
<button
class="pt-1.5 pb-0.5 px-1 hover:bg-surface-hover rounded transition-colors"
title="Configure default datatable & schema"
>
<Settings size={12} class="text-tertiary" />
</button>
</svelte:fragment>
<svelte:fragment slot="content">
<div class="flex flex-col gap-3 p-2 min-w-64 max-w-80">
<div class="text-xs font-medium text-primary">Default Datatable & Schema</div>
<p class="text-2xs text-tertiary leading-relaxed">
{description}
</p>
<div class="flex flex-col gap-1">
<span class="text-2xs text-tertiary">Database</span>
<Select
items={datatableItems}
bind:value={() => datatable, (v) => onChange?.(v, schema)}
placeholder="Select database"
size="sm"
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-2xs text-tertiary">Schema</span>
<Select
items={schemaItems}
bind:value={() => schema ?? '', (v) => onChange?.(datatable, v || undefined)}
placeholder="public"
size="sm"
/>
</div>
</div>
</svelte:fragment>
</Popover>
@@ -0,0 +1,237 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Button from '../common/button/Button.svelte'
import Select from '../select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import type { DataTableRef } from './dataTableRefUtils'
import { resource } from 'runed'
import { ArrowLeft, Expand, LoaderCircle, Minimize, Plus, RefreshCcw } from 'lucide-svelte'
import DBManagerContent from '../DBManagerContent.svelte'
import type { DbInput } from '../dbTypes'
import type { SelectedTable } from '../DBManager.svelte'
interface Props {
onAdd?: (ref: DataTableRef) => void
existingRefs?: DataTableRef[]
/** Z-index offset for the drawer, useful when opening from within modals */
offset?: number
}
let { onAdd, existingRefs = [], offset = 0 }: Props = $props()
let open = $state(false)
let selectedDatatable = $state<string | undefined>(undefined)
// For DB manager
let dbManagerContent: DBManagerContent | undefined = $state()
let hasReplResult = $state(false)
let isRefreshing = $state(false)
let windowWidth = $state(window.innerWidth)
let expand = $state(false)
// Multi-select mode: selected tables
let selectedTables = $state<SelectedTable[]>([])
// Selected schema/table from DBManager (for preview)
let selectedSchemaKey = $state<string | undefined>(undefined)
let selectedTableKey = $state<string | undefined>(undefined)
// Load available datatables from workspace
const datatables = resource<string[]>([], async () => {
if (!$workspaceStore) return []
try {
return await WorkspaceService.listDataTables({ workspace: $workspaceStore })
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
})
export function openDrawer() {
// Auto-select first datatable if only one exists
if (datatables.current.length === 1) {
selectedDatatable = datatables.current[0]
} else if (datatables.current.length > 1 && datatables.current.includes('main')) {
selectedDatatable = 'main'
} else {
selectedDatatable = undefined
}
selectedSchemaKey = undefined
selectedTableKey = undefined
selectedTables = []
expand = false
open = true
}
let initialTableKey: string | undefined = $state<string | undefined>(undefined)
let initialSchemaKey: string | undefined = $state<string | undefined>(undefined)
export function openDrawerWithRef(ref: DataTableRef) {
selectedDatatable = ref.datatable
selectedSchemaKey = ref.schema
selectedTableKey = ref.table
initialTableKey = ref.table
initialSchemaKey = ref.schema
selectedTables = []
expand = false
open = true
}
export function closeDrawer() {
open = false
dbManagerContent?.clearReplResult()
}
function handleAddTables() {
if (!selectedDatatable) {
sendUserToast('Please select a data table first', true)
return
}
if (selectedTables.length === 0) {
sendUserToast('Please select at least one table', true)
return
}
// Add all selected tables
for (const table of selectedTables) {
const ref: DataTableRef = {
datatable: selectedDatatable,
schema: table.schema,
table: table.table
}
onAdd?.(ref)
}
const count = selectedTables.length
sendUserToast(`Added ${count} table${count > 1 ? 's' : ''} to app`)
selectedTables = []
}
const datatableItems = $derived(
datatables.current.map((dt) => ({
value: dt,
label: dt
}))
)
const dbInput: DbInput | undefined = $derived(
selectedDatatable
? {
type: 'database' as const,
resourceType: 'postgresql' as const,
resourcePath: `datatable://${selectedDatatable}`,
specificSchema: initialSchemaKey,
specificTable: initialTableKey
}
: undefined
)
$effect(() => {
if (!open) {
expand = false
}
})
// Convert existingRefs to disabledTables format for the current datatable
const disabledTables = $derived(
existingRefs
.filter((ref) => ref.datatable === selectedDatatable && ref.schema && ref.table)
.map((ref) => ({ schema: ref.schema!, table: ref.table! }))
)
// Can add: has tables selected
const canAdd = $derived(selectedDatatable && selectedTables.length > 0)
</script>
<svelte:window bind:innerWidth={windowWidth} />
<Drawer bind:open size={expand ? `${windowWidth}px` : '1200px'} {offset}>
<DrawerContent
title="Data"
on:close={() => {
if (hasReplResult) {
dbManagerContent?.clearReplResult()
} else {
closeDrawer()
}
}}
CloseIcon={hasReplResult ? ArrowLeft : undefined}
noPadding
>
{#if dbInput && $workspaceStore}
{#key selectedDatatable}
<DBManagerContent
bind:this={dbManagerContent}
input={dbInput}
bind:hasReplResult
bind:isRefreshing
bind:selectedSchemaKey
bind:selectedTableKey
multiSelectMode={true}
bind:selectedTables
{disabledTables}
>
{#snippet dbSelector()}
{#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={selectedDatatable}
placeholder="Select data table"
size="md"
/>
{/if}
{/snippet}
</DBManagerContent>
{/key}
{:else}
<div class="flex items-center justify-center h-full text-tertiary">
<span>Select a data table to explore</span>
</div>
{/if}
{#snippet actions()}
<Button
variant="contained"
color="blue"
disabled={!canAdd}
on:click={handleAddTables}
startIcon={{ icon: Plus }}
size="xs"
>
{#if selectedTables.length > 0}
Add {selectedTables.length} table{selectedTables.length > 1 ? 's' : ''}
{:else}
Add to app
{/if}
</Button>
<Button
loading={isRefreshing}
on:click={() => dbManagerContent?.refresh()}
startIcon={{ icon: RefreshCcw }}
size="xs"
color="light"
disabled={!selectedDatatable}
>
Refresh
</Button>
<Button
on:click={() => (expand = !expand)}
startIcon={{ icon: expand ? Minimize : Expand }}
size="xs"
color="light"
/>
{/snippet}
</DrawerContent>
</Drawer>
@@ -0,0 +1,202 @@
<script lang="ts">
import { Plus, Database, Trash2, Table2, Star } from 'lucide-svelte'
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
import type { DataTableRef } from './dataTableRefUtils'
import DefaultDatabaseSelector from './DefaultDatabaseSelector.svelte'
// Re-export for backwards compatibility
export type { DataTableRef } from './dataTableRefUtils'
interface Props {
dataTableRefs: DataTableRef[]
/** Default datatable for new tables */
defaultDatatable?: string | undefined
/** Default schema for new tables */
defaultSchema?: string | undefined
onAdd?: () => void
onRemove?: (index: number) => void
onSelect?: (ref: DataTableRef, index: number) => void
onDefaultChange?: (datatable: string | undefined, schema: string | undefined) => void
selectedIndex?: number | undefined
/** When true, renders without PanelSection wrapper (for use in modals) */
standalone?: boolean
/** Hide the default database selector */
hideDefaultSelector?: boolean
}
let {
dataTableRefs = [],
defaultDatatable = undefined,
defaultSchema = undefined,
onAdd,
onRemove,
onSelect,
onDefaultChange,
selectedIndex = undefined,
standalone = false,
hideDefaultSelector = false
}: Props = $props()
// Group refs by datatable, then by schema
type GroupedRefs = Map<string, Map<string, { ref: DataTableRef; index: number }[]>>
const groupedRefs = $derived.by(() => {
const groups: GroupedRefs = new Map()
dataTableRefs.forEach((ref, index) => {
const datatableKey = ref.datatable
const schemaKey = ref.schema ?? ''
if (!groups.has(datatableKey)) {
groups.set(datatableKey, new Map())
}
const datatableGroup = groups.get(datatableKey)!
if (!datatableGroup.has(schemaKey)) {
datatableGroup.set(schemaKey, [])
}
datatableGroup.get(schemaKey)!.push({ ref, index })
})
return groups
})
// Sort entries with default datatable first
const sortedDatatableEntries = $derived.by(() => {
const entries = [...groupedRefs.entries()]
return entries.sort(([a], [b]) => {
if (a === defaultDatatable) return -1
if (b === defaultDatatable) return 1
return a.localeCompare(b)
})
})
// Helper to sort schema entries with default schema first
function sortSchemaEntries(
entries: [string, { ref: DataTableRef; index: number }[]][],
datatableName: string
) {
return entries.sort(([a], [b]) => {
const aIsDefault =
datatableName === defaultDatatable && (a === defaultSchema || (a === '' && !defaultSchema))
const bIsDefault =
datatableName === defaultDatatable && (b === defaultSchema || (b === '' && !defaultSchema))
if (aIsDefault) return -1
if (bIsDefault) return 1
return a.localeCompare(b)
})
}
</script>
{#snippet actionButtons()}
<div class="flex items-center">
<!-- Settings popover for default database/schema -->
{#if !hideDefaultSelector}
<DefaultDatabaseSelector
datatable={defaultDatatable}
schema={defaultSchema}
onChange={onDefaultChange}
/>
{/if}
<!-- Add datatable button -->
<button
onclick={() => onAdd?.()}
class="pt-1.5 pb-0.5 px-1 hover:bg-surface-hover rounded transition-colors flex items-center gap-0.5"
title="Add datatable reference"
>
<Plus size={12} class="text-secondary" />
<Database size={12} class="text-tertiary" />
</button>
</div>
{/snippet}
{#snippet tableList()}
{#if dataTableRefs.length === 0}
<span class="text-2xs text-tertiary">No tables referenced yet</span>
{:else}
<div class="flex flex-col w-full">
{#each sortedDatatableEntries as [datatableName, schemaGroups] (datatableName)}
{@const isDefaultDatatable = datatableName === defaultDatatable}
<!-- Datatable header -->
<div class="flex items-center gap-1.5 px-1 py-1 text-2xs text-tertiary">
<Database size={12} class="shrink-0" />
<span class="font-medium truncate">{datatableName}</span>
{#if isDefaultDatatable}
<span title="Default datatable">
<Star size={10} class="shrink-0 text-primary" />
</span>
{/if}
</div>
{#each sortSchemaEntries([...schemaGroups.entries()], datatableName) as [schemaName, items] (schemaName)}
{@const isDefaultSchema =
isDefaultDatatable &&
(schemaName === defaultSchema || (schemaName === '' && !defaultSchema))}
<!-- Schema header (only if schema exists) -->
{#if schemaName}
<div class="flex items-center gap-1.5 pl-4 py-0.5 text-2xs text-tertiary">
<span class="truncate">{schemaName}</span>
{#if isDefaultSchema}
<span title="Default schema">
<Star size={8} class="shrink-0 text-primary" />
</span>
{/if}
</div>
{/if}
<!-- Tables -->
{#each items as { ref, index } (index)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group flex items-center gap-2 py-1 rounded text-left w-full hover:bg-surface-hover transition-colors cursor-pointer {selectedIndex ===
index
? 'bg-surface-selected'
: ''}"
class:pl-7={schemaName}
class:pl-4={!schemaName}
onclick={() => onSelect?.(ref, index)}
>
<Table2 size={12} class="text-tertiary shrink-0" />
<span class="text-2xs text-secondary truncate flex-1">
{ref.table ?? '(all tables)'}
</span>
<button
class="p-1 hover:bg-red-500/10 rounded transition-colors opacity-0 group-hover:opacity-100 mr-1"
onclick={(e) => {
e.stopPropagation()
onRemove?.(index)
}}
title="Remove table reference"
>
<Trash2 size={10} class="text-red-500" />
</button>
</div>
{/each}
{/each}
{/each}
</div>
{/if}
{/snippet}
{#if standalone}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs text-tertiary">Existing tables to use</span>
{@render actionButtons()}
</div>
{@render tableList()}
</div>
{:else}
<PanelSection
fullHeight={false}
size="lg"
title="data"
id="app-editor-data-panel"
tooltip="Data tables to use in the app. Adding some here does not change the behavior of the app, since they can be used in code directly regardless. But it allows AI to always keep their schema in context as well as allowing quick access and clear view of the app's data layer."
>
{#snippet action()}
{@render actionButtons()}
{/snippet}
{@render tableList()}
</PanelSection>
{/if}
@@ -18,14 +18,25 @@
import { isRunnableByName, isRunnableByPath } from '../apps/inputType'
import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte'
import { onMount } from 'svelte'
import type { LintResult } from '../copilot/chat/app/core'
import type { LintResult, DataTableInfo, DataTableTableSchema } from '../copilot/chat/app/core'
import { rawAppLintStore } from './lintStore'
import { dbSchemas, type DBSchema } from '$lib/stores'
import { getDbSchemas } from '../apps/components/display/dbtable/metadata'
import { runScriptAndPollResult } from '../jobs/utils'
import { RawAppHistoryManager } from './RawAppHistoryManager.svelte'
import { sendUserToast } from '$lib/utils'
import {
parseDataTableRef,
formatDataTableRef,
type RawAppData,
DEFAULT_DATA
} from './dataTableRefUtils'
interface Props {
initFiles: Record<string, string>
initRunnables: Record<string, Runnable>
/** Data configuration including tables and creation policy */
initData: RawAppData | undefined
newApp: boolean
policy: Policy
summary?: string
@@ -48,6 +59,7 @@
let {
initFiles,
initRunnables,
initData,
newApp,
policy,
summary = $bindable(''),
@@ -60,6 +72,11 @@
let runnables = $state(initRunnables)
// Data configuration with tables and creation policy
let data: RawAppData = $state(initData ?? DEFAULT_DATA)
// Convert to object format for child components
let dataTableRefsObjects = $derived(data.tables.map(parseDataTableRef))
let initRunnablesContent = Object.fromEntries(
Object.entries(initRunnables).map(([key, runnable]) => {
if (isRunnableByName(runnable)) {
@@ -76,7 +93,7 @@
maxEntries: 50,
autoSnapshotInterval: 5 * 60 * 1000 // 5 minutes
})
historyManager.manualSnapshot(files ?? {}, runnables, summary)
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
let draftTimeout: number | undefined = undefined
function saveFrontendDraft() {
@@ -87,7 +104,8 @@
path != '' ? `rawapp-${path}` : 'rawapp',
encodeState({
files,
runnables: runnables
runnables: runnables,
data: data
})
)
} catch (err) {
@@ -187,11 +205,19 @@
aiChatManager.changeMode(AIMode.APP)
rawAppLintStore.enable()
// Initialize aiChatManager.datatableCreationPolicy from stored data
aiChatManager.datatableCreationPolicy = {
enabled: data.datatable !== undefined,
datatable: data.datatable,
schema: data.schema
}
// Start auto-snapshot
historyManager.startAutoSnapshot(() => ({
files: files ?? {},
runnables,
summary
summary,
data
}))
return () => {
@@ -200,6 +226,18 @@
}
})
// Sync data with aiChatManager.datatableCreationPolicy (bidirectional)
$effect(() => {
// Read the current policy from aiChatManager
const policy = aiChatManager.datatableCreationPolicy
// Only update if different to avoid infinite loops
if (data.datatable !== policy.datatable || data.schema !== policy.schema) {
data.datatable = policy.datatable
data.schema = policy.schema
saveFrontendDraft()
}
})
$effect(() => {
function lint(): LintResult {
const snapshot = rawAppLintStore.getSnapshot()
@@ -373,13 +411,128 @@
snapshot: () => {
// Force create snapshot for AI - it needs a restore point
return (
historyManager.manualSnapshot(files ?? {}, runnables, summary, true)?.id ??
historyManager.manualSnapshot(files ?? {}, runnables, summary, data, true)?.id ??
historyManager.getId()
)
},
revertToSnapshot: (id: number) => {
console.log('reverting to snapshot', id)
handleHistorySelect(id)
},
getDatatables: async (): Promise<DataTableInfo[]> => {
const results: DataTableInfo[] = []
// Get unique datatable names from dataTableRefs
const datatableNames = [...new Set(dataTableRefsObjects.map((ref) => ref.datatable))]
for (const datatableName of datatableNames) {
const resourcePath = `datatable://${datatableName}`
// Get or load the schema
let schema: DBSchema | undefined = $dbSchemas[resourcePath]
if (!schema) {
try {
await getDbSchemas('postgresql', resourcePath, $workspaceStore, $dbSchemas, (msg) =>
console.error('Schema error:', msg)
)
schema = $dbSchemas[resourcePath]
} catch (e) {
console.error(`Failed to load schema for ${datatableName}:`, e)
continue
}
}
if (!schema?.schema) continue
// Get the tables for this datatable from the refs
const refsForDatatable = dataTableRefsObjects.filter(
(ref) => ref.datatable === datatableName
)
const tables: DataTableTableSchema[] = []
for (const ref of refsForDatatable) {
const schemaKey = ref.schema || 'public'
const tableKey = ref.table
if (!tableKey) continue // Skip if no table specified
const tableSchema = schema.schema[schemaKey]?.[tableKey]
if (!tableSchema) continue
const columns: Record<string, { type: string; required: boolean }> = {}
for (const [colName, colDef] of Object.entries(tableSchema)) {
columns[colName] = {
type: (colDef as any).type || 'unknown',
required: (colDef as any).required || false
}
}
tables.push({
schema: schemaKey,
table: tableKey,
columns
})
}
results.push({
name: datatableName,
tables
})
}
return results
},
getAvailableDatatableNames: (): string[] => {
// Get unique datatable names from dataTableRefs
return [...new Set(dataTableRefsObjects.map((ref) => ref.datatable))]
},
execDatatableSql: async (
datatableName: string,
sql: string,
newTable?: { schema: string; name: string }
): Promise<{ success: boolean; result?: Record<string, any>[]; error?: string }> => {
if (!$workspaceStore) {
return { success: false, error: 'Workspace not available' }
}
try {
const result = await runScriptAndPollResult({
workspace: $workspaceStore,
requestBody: {
language: 'postgresql',
content: sql,
args: { database: `datatable://${datatableName}` }
}
})
// If newTable was specified and the query succeeded, add it to data.tables
if (newTable) {
const newRef = formatDataTableRef({
datatable: datatableName,
schema: newTable.schema === 'public' ? undefined : newTable.schema,
table: newTable.name
})
// Only add if not already present
if (!data.tables.includes(newRef)) {
data.tables = [...data.tables, newRef]
saveFrontendDraft()
// Clear the cached schema so it gets refreshed with the new table
const resourcePath = `datatable://${datatableName}`
delete $dbSchemas[resourcePath]
}
}
// Check if result is an array (SELECT) or something else
if (Array.isArray(result)) {
return { success: true, result }
} else {
return { success: true, result: [] }
}
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e)
return { success: false, error: errorMsg }
}
}
})
})
@@ -448,7 +601,7 @@
function handleUndo() {
// Create a snapshot if we're at the latest position with pending changes
if (historyManager.needsSnapshotBeforeNav) {
historyManager.manualSnapshot(files ?? {}, runnables, summary)
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
}
const entry = historyManager.undo()
@@ -467,7 +620,7 @@
function handleHistorySelect(id: number) {
// Create a snapshot if we have pending changes before navigating
if (historyManager.needsSnapshotBeforeNav) {
historyManager.manualSnapshot(files ?? {}, runnables, summary)
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
}
const entry = historyManager.selectEntry(id)
@@ -480,11 +633,13 @@
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
data: RawAppData
}) {
try {
files = structuredClone($state.snapshot(entry.files))
runnables = structuredClone($state.snapshot(entry.runnables))
summary = entry.summary
data = structuredClone($state.snapshot(entry.data))
setFilesInIframe(entry.files)
populateRunnables()
@@ -509,7 +664,7 @@
// Ctrl/Cmd + Shift + H for manual snapshot
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'H') {
e.preventDefault()
historyManager.manualSnapshot(files ?? {}, runnables, summary)
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
}
}
</script>
@@ -540,6 +695,7 @@
{newPath}
appPath={path}
{files}
{data}
{runnables}
{getBundle}
canUndo={historyManager.canUndo}
@@ -561,13 +717,31 @@
onSelectFile={handleSelectFile}
bind:selectedRunnable
bind:selectedDocument
dataTableRefs={dataTableRefsObjects}
onDataTableRefsChange={(newRefs) => {
data.tables = newRefs.map(formatDataTableRef)
saveFrontendDraft()
}}
defaultDatatable={data.datatable}
defaultSchema={data.schema}
onDefaultChange={(datatable, schema) => {
data.datatable = datatable
data.schema = schema
// Also sync to aiChatManager
aiChatManager.datatableCreationPolicy = {
...aiChatManager.datatableCreationPolicy,
datatable,
schema
}
saveFrontendDraft()
}}
{runnables}
{modules}
{historyManager}
historySelectedId={historyManager.selectedEntryId}
onHistorySelect={handleHistorySelect}
onManualSnapshot={() => {
historyManager.manualSnapshot(files ?? {}, runnables, summary, true)
historyManager.manualSnapshot(files ?? {}, runnables, summary, data, true)
}}
></RawAppSidebar>
</Pane>
@@ -47,7 +47,8 @@
import { updateRawAppPolicy } from './rawAppPolicy'
import { aiChatManager } from '../copilot/chat/AIChatManager.svelte'
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
import type { RawAppData } from './dataTableRefUtils'
// async function hash(message) {
// try {
// const msgUint8 = new TextEncoder().encode(message) // encode as (utf-8) Uint8Array
@@ -86,6 +87,8 @@
appPath: string
runnables: Record<string, Runnable>
files: Record<string, string> | undefined
/** Data configuration including tables and creation policy */
data: RawAppData
jobs: string[]
jobsById: Record<string, any>
getBundle: () => Promise<{
@@ -108,6 +111,7 @@
newPath = '',
appPath,
runnables,
data,
files,
jobs = $bindable(),
jobsById = $bindable(),
@@ -583,7 +587,7 @@
}
}
}
let app = $derived(files ? { runnables: runnables, files } : undefined)
let app = $derived(files ? { runnables: runnables, files, data } : undefined)
$effect(() => {
saveDrawerOpen && compareVersions()
@@ -1,5 +1,6 @@
import type { Runnable } from './utils'
import { deepEqual } from 'fast-equals'
import type { RawAppData } from './dataTableRefUtils'
/**
* Snapshot entry containing raw app state at a point in time
@@ -10,6 +11,7 @@ export interface HistoryEntry {
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
data: RawAppData
}
/**
@@ -55,6 +57,7 @@ export class RawAppHistoryManager {
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
data: RawAppData
})
| undefined = undefined
private isCreatingSnapshot = $state(false)
@@ -108,14 +111,16 @@ export class RawAppHistoryManager {
createSnapshot(
files: Record<string, string>,
runnables: Record<string, Runnable>,
summary: string
summary: string,
data: RawAppData
): HistoryEntry {
return {
id: this.entryIdCounter++,
timestamp: new Date(),
files: structuredClone($state.snapshot(files)),
runnables: structuredClone($state.snapshot(runnables)),
summary: $state.snapshot(summary)
summary: $state.snapshot(summary),
data: structuredClone($state.snapshot(data))
}
}
@@ -125,7 +130,8 @@ export class RawAppHistoryManager {
private hasStateChanged(
files: Record<string, string>,
runnables: Record<string, Runnable>,
summary: string
summary: string,
data: RawAppData
): boolean {
if (this.entries.length === 0) return true
@@ -133,7 +139,8 @@ export class RawAppHistoryManager {
return (
!deepEqual(lastEntry.files, files) ||
!deepEqual(lastEntry.runnables, runnables) ||
lastEntry.summary !== summary
lastEntry.summary !== summary ||
!deepEqual(lastEntry.data, data)
)
}
@@ -257,13 +264,14 @@ export class RawAppHistoryManager {
files: Record<string, string>,
runnables: Record<string, Runnable>,
summary: string,
data: RawAppData,
force = false
): HistoryEntry | undefined {
if (!force && !this.hasStateChanged(files, runnables, summary)) {
if (!force && !this.hasStateChanged(files, runnables, summary, data)) {
return
}
const entry = this.createSnapshot(files, runnables, summary)
const entry = this.createSnapshot(files, runnables, summary, data)
this.addSnapshot(entry)
return entry
}
@@ -276,6 +284,7 @@ export class RawAppHistoryManager {
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
data: RawAppData
}
): void {
this.stopAutoSnapshot()
@@ -285,8 +294,8 @@ export class RawAppHistoryManager {
this.autoSnapshotTimer = setInterval(() => {
if (this.getStateFn && this.currentIndex === -1 && this.currentBranchId === undefined) {
const { files, runnables, summary } = this.getStateFn()
this.manualSnapshot(files, runnables, summary)
const { files, runnables, summary, data } = this.getStateFn()
this.manualSnapshot(files, runnables, summary, data)
}
}, this.config.autoSnapshotInterval) as unknown as number
}
@@ -10,6 +10,9 @@
import RawAppHistoryList from './RawAppHistoryList.svelte'
import type { RawAppHistoryManager } from './RawAppHistoryManager.svelte'
import Button from '../common/button/Button.svelte'
import RawAppDataTableList from './RawAppDataTableList.svelte'
import type { DataTableRef } from './dataTableRefUtils'
import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte'
interface Props {
runnables: Record<string, Runnable>
@@ -22,6 +25,13 @@
historySelectedId?: number | undefined
onHistorySelect?: (id: number) => void
onManualSnapshot?: () => void
dataTableRefs?: DataTableRef[]
onDataTableRefsChange?: (refs: DataTableRef[]) => void
/** Default datatable for new tables */
defaultDatatable?: string | undefined
/** Default schema for new tables */
defaultSchema?: string | undefined
onDefaultChange?: (datatable: string | undefined, schema: string | undefined) => void
}
let {
@@ -34,9 +44,36 @@
historyManager,
historySelectedId,
onHistorySelect,
onManualSnapshot
onManualSnapshot,
dataTableRefs = [],
onDataTableRefsChange,
defaultDatatable = undefined,
defaultSchema = undefined,
onDefaultChange
}: Props = $props()
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
let selectedDataTableIndex: number | undefined = $state(undefined)
function handleAddDataTable(ref: DataTableRef) {
onDataTableRefsChange?.([...dataTableRefs, ref])
}
function handleRemoveDataTable(index: number) {
onDataTableRefsChange?.(dataTableRefs.filter((_, i) => i !== index))
if (selectedDataTableIndex === index) {
selectedDataTableIndex = undefined
}
}
function handleSelectDataTable(ref: DataTableRef, index: number) {
selectedDataTableIndex = selectedDataTableIndex === index ? undefined : index
// Open the drawer in manage mode when selecting a data table
if (selectedDataTableIndex === index) {
dataTableDrawer?.openDrawerWithRef(ref)
}
}
const fileTree = $derived(buildFileTree(Object.keys(files ?? {})))
let pathToRename = $state<string | undefined>(undefined)
@@ -293,9 +330,21 @@
<RawAppInlineScriptPanelList bind:selectedRunnable {runnables} />
<div class="py-4"></div>
<PanelSection fullHeight={false} size="lg" title="data">
<span class="text-2xs text-tertiary">Coming soon</span>
</PanelSection>
<RawAppDataTableList
{dataTableRefs}
{defaultDatatable}
{defaultSchema}
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={handleRemoveDataTable}
onSelect={handleSelectDataTable}
{onDefaultChange}
selectedIndex={selectedDataTableIndex}
/>
<RawAppDataTableDrawer
bind:this={dataTableDrawer}
onAdd={handleAddDataTable}
existingRefs={dataTableRefs}
/>
{#if historyManager && onHistorySelect && onManualSnapshot}
<div class="py-4"></div>
@@ -0,0 +1,63 @@
/** Internal representation of a data table reference */
export interface DataTableRef {
/** The datatable name from workspace settings */
datatable: string
/** Optional schema filter */
schema?: string
/** Optional table filter */
table?: string
}
/** Top-level data configuration for raw apps */
export interface RawAppData {
/** Table references for the app */
tables: string[]
/** The datatable name for table creation (if specified) */
datatable: string | undefined
/** The schema for table creation (if specified) */
schema: string | undefined
}
/** Default data configuration */
export const DEFAULT_DATA: RawAppData = {
tables: [],
datatable: undefined,
schema: undefined
}
/**
* Parse a string ref into a DataTableRef object
* Format: <datatableName>/<schema>:<table> or <datatableName>/<table> (for public schema)
*/
export function parseDataTableRef(ref: string): DataTableRef {
const slashIdx = ref.indexOf('/')
if (slashIdx === -1) {
return { datatable: ref }
}
const datatable = ref.slice(0, slashIdx)
const rest = ref.slice(slashIdx + 1)
const colonIdx = rest.indexOf(':')
if (colonIdx === -1) {
// No colon means public schema: <datatableName>/<table>
return { datatable, schema: 'public', table: rest }
}
// Has colon: <datatableName>/<schema>:<table>
const schema = rest.slice(0, colonIdx)
const table = rest.slice(colonIdx + 1)
return { datatable, schema, table }
}
/**
* Format a DataTableRef object into a string
* Format: <datatableName>/<schema>:<table> or <datatableName>/<table> (for public schema)
*/
export function formatDataTableRef(ref: DataTableRef): string {
if (!ref.table) {
return ref.datatable
}
if (!ref.schema || ref.schema === 'public') {
return `${ref.datatable}/${ref.table}`
}
return `${ref.datatable}/${ref.schema}:${ref.table}`
}
@@ -0,0 +1,77 @@
import { resource } from 'runed'
import { workspaceStore, dbSchemas } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
import { get } from 'svelte/store'
/**
* Creates a resource that loads available datatables from the workspace.
* Pass a getter function that returns the workspace to create a reactive dependency.
*/
export function createDatatablesResource(getWorkspace: () => string | undefined) {
return resource.pre<string[]>([() => getWorkspace() ?? ''], async () => {
const workspace = getWorkspace()
if (!workspace) return []
try {
return await WorkspaceService.listDataTables({ workspace })
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
})
}
/**
* Creates a resource that loads schemas for a given datatable.
* The getDatatable getter is used as a reactive dependency - when it changes, schemas are refetched.
*/
export function createSchemasResource(getDatatable: () => string | undefined) {
return resource<string[]>([() => getDatatable() ?? ''], async () => {
const datatable = getDatatable()
const workspace = get(workspaceStore)
if (!datatable || !workspace) return []
const resourcePath = `datatable://${datatable}`
const schemas = get(dbSchemas)
let dbSchema = schemas[resourcePath]
if (!dbSchema) {
try {
await getDbSchemas('postgresql', resourcePath, workspace, schemas, (msg) =>
console.error('Schema error:', msg)
)
dbSchema = get(dbSchemas)[resourcePath]
} catch (e) {
console.error(`Failed to load schema for ${datatable}:`, e)
return []
}
}
if (!dbSchema?.schema) return []
return Object.keys(dbSchema.schema)
})
}
/**
* Converts datatables array to Select items format
*/
export function toDatatableItems(datatables: string[]) {
return (
datatables?.map((dt) => ({
value: dt,
label: dt
})) ?? []
)
}
/**
* Converts schemas array to Select items format
*/
export function toSchemaItems(schemas: string[]) {
return (
schemas?.map((s) => ({
value: s,
label: s
})) ?? []
)
}
+5 -1
View File
@@ -1788,7 +1788,11 @@ export function getQueryStmtCountHeuristic(query: string): number {
const trimmedQuery = query.trimEnd()
if (currState === 'normal' && trimmedQuery !== '' && !trimmedQuery.endsWith(';')) {
count++
} else if (currState === 'single-quote' || currState === 'double-quote' || currState === 'block-comment') {
} else if (
currState === 'single-quote' ||
currState === 'double-quote' ||
currState === 'block-comment'
) {
// Unclosed quote or unclosed block comment means there's an implicit statement
count++
} else if (currState === 'line-comment' && hasContentAfterLastSemicolon) {
@@ -14,6 +14,25 @@
import FileEditorIcon from '$lib/components/raw_apps/FileEditorIcon.svelte'
import { react18Template, react19Template, svelte5Template } from './templates'
import type { Runnable } from '$lib/components/raw_apps/rawAppPolicy'
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
createDatatablesResource,
createSchemasResource,
toDatatableItems,
toSchemaItems
} from '$lib/components/raw_apps/datatableUtils.svelte'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { AlertTriangle, Sparkles, ArrowRight, Plus, List, Ban } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import RawAppDataTableList from '$lib/components/raw_apps/RawAppDataTableList.svelte'
import RawAppDataTableDrawer from '$lib/components/raw_apps/RawAppDataTableDrawer.svelte'
import { type DataTableRef, formatDataTableRef } from '$lib/components/raw_apps/dataTableRefUtils'
import { copilotInfo } from '$lib/aiStore'
import { aiChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
let nodraft = $page.url.searchParams.get('nodraft')
const templatePath = $page.url.searchParams.get('template')
@@ -68,11 +87,29 @@
}
}
})
/** Data configuration including tables and creation policy */
let data: RawAppData = $state({ ...DEFAULT_DATA })
loadApp()
function extractValue(value: any) {
files = value.files
runnables = value.runnables
// Support old formats and new format
if (value.data) {
const d = value.data
// Handle old nested creation format
if (d.creation) {
data = {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (value.dataTableRefs) {
data = { ...DEFAULT_DATA, tables: value.dataTableRefs }
}
}
async function loadApp() {
if (importRaw) {
@@ -137,44 +174,388 @@
icon: 'svelte',
files: svelte5Template
}
// {
// name: 'Vue 3',
// icon: 'vue',
// files: vueTemplate
// }
]
let templatePicker = $state(nodraft != null)
let reloadCounter = $state(0)
// Modal state
let selectedTemplateIndex = $state(0)
let tableCreationEnabled = $state(true)
let selectedDatatable = $state<string | undefined>(undefined)
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
let selectedSchema = $state<string | undefined>(undefined)
let newSchemaName = $state('')
let appSummary = $state('')
let initialPrompt = $state('')
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
// Pre-whitelisted tables for the app
let preWhitelistedTables = $state<DataTableRef[]>([])
// Load available datatables and schemas using shared utilities
const datatables = createDatatablesResource(() => $workspaceStore)
const schemas = createSchemasResource(() => selectedDatatable)
// Derived value to force reactivity on datatables.current
const availableDatatables = $derived(datatables.current)
const availableSchemas = $derived(schemas.current)
// Auto-select datatable: prefer "main" if available, otherwise first one
// Only runs once when datatables first load (selectedDatatable is undefined)
let hasAutoSelected = false
$effect(() => {
if (availableDatatables?.length > 0 && !hasAutoSelected) {
hasAutoSelected = true
if (availableDatatables.includes('main')) {
selectedDatatable = 'main'
} else {
selectedDatatable = availableDatatables[0]
}
}
})
// Generate unique schema name (appX where X is first unused number)
function generateUniqueSchemaName(existingSchemas: string[]): string {
let num = 1
while (existingSchemas.includes(`app${num}`)) {
num++
}
return `app${num}`
}
// Check if new schema name already exists
const newSchemaAlreadyExists = $derived(
schemaMode === 'new' &&
newSchemaName.trim() !== '' &&
(availableSchemas ?? []).includes(newSchemaName.trim())
)
// Track if the user has manually edited the schema name
let userEditedSchemaName = $state(false)
// Set default new schema name when schemas load or when switching to new mode
// Also auto-fix if the current name exists and was auto-generated (not user-edited)
$effect(() => {
const schemas = availableSchemas ?? []
if (schemaMode === 'new') {
if (!newSchemaName) {
// Initial load: set default name
newSchemaName = generateUniqueSchemaName(schemas)
userEditedSchemaName = false
} else if (!userEditedSchemaName && schemas.includes(newSchemaName)) {
// Auto-generated name now exists (schemas reloaded), regenerate
newSchemaName = generateUniqueSchemaName(schemas)
}
}
})
// Reset schema when datatable changes
let previousDatatable = $state<string | undefined>(undefined)
$effect(() => {
if (previousDatatable !== undefined && selectedDatatable !== previousDatatable) {
selectedSchema = undefined
newSchemaName = ''
userEditedSchemaName = false
}
previousDatatable = selectedDatatable
})
// Update AI prompt when summary changes
$effect(() => {
if (appSummary.trim() && isAiEnabled) {
initialPrompt = `Build ${appSummary.trim()}`
}
})
const datatableItems = $derived(toDatatableItems(availableDatatables))
const schemaItems = $derived(toSchemaItems(availableSchemas))
// The effective schema to use (either selected existing, new schema name, or undefined for none)
const effectiveSchema = $derived(
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
)
const hasNoDatatables = $derived(availableDatatables?.length === 0)
const isAiEnabled = $derived($copilotInfo.enabled)
async function startApp(withPrompt: boolean) {
const template = templates[selectedTemplateIndex]
if (template.files) {
files = template.files
reloadCounter += 1
}
// Set summary
summary = appSummary.trim()
// Create new schema if needed
if (schemaMode === 'new' && newSchemaName && selectedDatatable && $workspaceStore) {
try {
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
const dbOps = dbSchemaOpsWithPreviewScripts({
workspace: $workspaceStore,
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
} catch (e) {
console.error('Failed to create schema:', e)
sendUserToast(`Failed to create schema: ${e}`, true)
}
}
// Set the data configuration including pre-whitelisted tables
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
if (tableCreationEnabled && selectedDatatable) {
data = {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema
}
} else {
data = {
tables: formattedTables,
datatable: undefined,
schema: undefined
}
}
// Sync to aiChatManager
aiChatManager.datatableCreationPolicy = {
enabled: tableCreationEnabled && !!selectedDatatable,
datatable: tableCreationEnabled ? selectedDatatable : undefined,
schema: tableCreationEnabled ? effectiveSchema : undefined
}
templatePicker = false
// Remove nodraft from URL
const url = new URL(window.location.href)
if (url.searchParams.has('nodraft')) {
url.searchParams.delete('nodraft')
window.history.replaceState({}, '', url.toString())
}
// If starting with a prompt, trigger AI after a short delay for the editor to initialize
if (withPrompt && initialPrompt.trim() && isAiEnabled) {
setTimeout(() => {
aiChatManager.changeMode(AIMode.APP)
if (!aiChatManager.open) {
aiChatManager.toggleOpen()
}
aiChatManager.instructions = initialPrompt.trim()
aiChatManager.sendRequest()
}, 500)
}
}
</script>
{#if templatePicker}
<Modal kind="X" open title="Templates">
<div class="flex flex-wrap gap-4 pb-4">
{#each templates as t}
<button
onclick={() => {
if (t.files) {
files = t.files
reloadCounter += 1
}
templatePicker = false
// Remove nodraft from URL when a template is selected
const url = new URL(window.location.href)
if (url.searchParams.has('nodraft')) {
url.searchParams.delete('nodraft')
window.history.replaceState({}, '', url.toString())
}
<Modal kind="X" open title="New App setup">
<div class="flex flex-col gap-6 min-w-[500px]">
<!-- Summary -->
<div>
<h2 class="text-sm font-medium text-primary mb-2">Summary</h2>
<TextInput
bind:value={appSummary}
inputProps={{
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
}}
class="w-24 h-24 flex justify-between py-5 flex-col {t.selected
? 'bg-surface-selected'
: ''} hover:bg-surface-hover border rounded-lg"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} />
/>
</div>
<!-- Template Selection -->
<div class="border-t pt-4">
<h2 class="text-sm font-medium text-primary mb-2">Framework</h2>
<div class="flex flex-wrap gap-3">
{#each templates as t, i}
<button
onclick={() => (selectedTemplateIndex = i)}
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
? 'bg-surface-selected ring-2 ring-blue-500'
: ''} hover:bg-surface-hover border rounded-lg transition-all"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} />
</div>
<div class="center-center w-full text-sm">{t.name}</div>
</button>
{/each}
</div>
</div>
<!-- Data Configuration -->
<div class="border-t pt-4">
<h2 class="text-sm font-medium text-primary mb-3">Data Configuration</h2>
{#if hasNoDatatables}
<div
class="flex items-center gap-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800"
>
<AlertTriangle size={16} class="text-yellow-600 dark:text-yellow-400 shrink-0" />
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<span class="font-medium">No datatables configured.</span>
You can still create an app, but AI won't be able to create database tables. Configure
datatables in workspace settings to enable this feature.
</div>
</div>
<div class="center-center w-full">{t.name}</div>
</button>
{/each}
{:else}
<div class="flex flex-col gap-4">
<!-- Default Datatable & Schema -->
<div>
<span class="text-xs text-tertiary mb-1 block">Default settings for new tables</span>
<div class="flex flex-col gap-1">
<div class="flex gap-2 items-center">
<Select
transformInputSelectedText={(text) => 'datatable: ' + text}
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
</div>
<div>
<span class="text-2xs text-tertiary">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
<div>
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
<ToggleButton
value="existing"
label="Existing"
icon={List}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{#if schemaMode === 'new'}
<TextInput
bind:value={newSchemaName}
inputProps={{
placeholder: 'Schema name',
oninput: () => (userEditedSchemaName = true)
}}
class="flex-1"
error={newSchemaAlreadyExists}
/>
{:else if schemaMode === 'existing'}
<div class="flex-1">
<Select
disablePortal
items={schemaItems}
bind:value={selectedSchema}
placeholder="Schema"
size="sm"
/>
</div>
{/if}
</div>
{#if newSchemaAlreadyExists}
<span class="text-xs text-red-500">Schema "{newSchemaName}" already exists</span
>
{/if}
</div>
</div>
</div>
<!-- Table Creation Toggle -->
<div class="flex items-center">
<Toggle
size="sm"
bind:checked={tableCreationEnabled}
options={{ right: 'Allow AI to create new tables' }}
/>
</div>
<!-- Pre-whitelisted Tables -->
<div class="border-t pt-3">
<RawAppDataTableList
dataTableRefs={preWhitelistedTables}
defaultDatatable={selectedDatatable}
defaultSchema={effectiveSchema}
standalone
hideDefaultSelector
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={(index) => {
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
}}
/>
</div>
</div>
{/if}
</div>
<!-- AI Prompt (Optional) -->
<div class="border-t pt-4">
<h2 class="text-sm font-medium text-primary mb-2 flex items-center gap-2">
<Sparkles size={16} class="text-blue-500" />
Start with AI
<span class="text-xs font-normal text-tertiary">(optional)</span>
</h2>
{#if !isAiEnabled}
<div
class="flex items-center gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
>
<AlertTriangle size={16} class="text-gray-500 shrink-0" />
<div class="text-sm text-tertiary">
AI is not configured for this workspace. You can still create an app manually.
</div>
</div>
{:else}
<div class="flex flex-col gap-2">
<TextInput
underlyingInputEl="textarea"
bind:value={initialPrompt}
inputProps={{
rows: 3,
placeholder:
"Describe what you want to build... (e.g., 'Create a todo list app with user authentication')"
}}
/>
<p class="text-xs text-tertiary">
Leave empty to start with a blank template, or describe your app to get AI assistance
right away.
</p>
</div>
{/if}
</div>
<!-- Actions -->
<div class="border-t pt-4 flex justify-end gap-3">
<Button
color="light"
size="sm"
on:click={() => startApp(false)}
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
>
Start without AI
</Button>
{#if isAiEnabled}
<Button
color="blue"
size="sm"
on:click={() => startApp(true)}
disabled={!templates[selectedTemplateIndex] ||
!initialPrompt.trim() ||
newSchemaAlreadyExists}
startIcon={{ icon: Sparkles }}
endIcon={{ icon: ArrowRight }}
>
Start with AI
</Button>
{/if}
</div>
</div>
</Modal>
{/if}
@@ -185,9 +566,19 @@
}}
initFiles={files}
initRunnables={runnables}
initData={data}
{policy}
path={''}
{summary}
newApp
/>
{/key}
<RawAppDataTableDrawer
bind:this={dataTableDrawer}
offset={10000}
existingRefs={preWhitelistedTables}
onAdd={(ref) => {
preWhitelistedTables = [...preWhitelistedTables, ref]
}}
/>
@@ -1,4 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { AppService, DraftService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanValueProperties, decodeState, type Value } from '$lib/utils'
@@ -10,13 +12,15 @@
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
let files: Record<string, string> | undefined = undefined
let runnables = {}
let newPath = ''
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
let files: Record<string, string> | undefined = $state(undefined)
let runnables = $state({})
/** Data configuration including tables and creation policy */
let data: RawAppData = $state({ ...DEFAULT_DATA })
let newPath = $state('')
// let lastVersion = 0
let policy: any = {}
let summary = ''
let policy: any = $state({})
let summary = $state('')
let savedApp:
| {
@@ -31,8 +35,8 @@
draft_only?: boolean
custom_path?: string
}
| undefined = undefined
let redraw = 0
| undefined = $state(undefined)
let redraw = $state(0)
let path = page.params.path ?? ''
let nodraft = page.url.searchParams.get('nodraft')
@@ -47,6 +51,22 @@
function extractRawApp(app: any) {
runnables = app.value.runnables
// Support old formats and new format
if (app.value.data) {
const d = app.value.data
// Handle old nested creation format
if (d.creation) {
data = {
tables: d.tables ?? [],
datatable: d.creation.datatable,
schema: d.creation.schema
}
} else {
data = d
}
} else if (app.value.datatables) {
data = { ...DEFAULT_DATA, tables: app.value.datatables }
}
files = app.value.files
summary = app.summary
// lastVersion = app.version
@@ -95,8 +115,8 @@
actions.push({
label: 'Show diff',
callback: async () => {
diffDrawer.openDrawer()
diffDrawer.setDiff({
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: draftOrDeployed,
current: urlScript,
@@ -131,8 +151,8 @@
{
label: 'Show diff',
callback: async () => {
diffDrawer.openDrawer()
diffDrawer.setDiff({
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
@@ -148,18 +168,18 @@
}
}
$: {
run(() => {
if ($workspaceStore) {
loadApp()
}
}
})
async function restoreDraft() {
if (!savedApp || !savedApp.draft) {
sendUserToast('Could not restore to draft', true)
return
}
diffDrawer.closeDrawer()
diffDrawer?.closeDrawer()
goto(`/apps/edit/${savedApp.draft.path}`)
await loadApp()
redraw++
@@ -170,7 +190,7 @@
sendUserToast('Could not restore to deployed', true)
return
}
diffDrawer.closeDrawer()
diffDrawer?.closeDrawer()
if (savedApp.draft) {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
@@ -183,7 +203,7 @@
redraw++
}
let diffDrawer: DiffDrawer
let diffDrawer: DiffDrawer | undefined = $state(undefined)
function onRestore(ev: any) {
sendUserToast('App restored from previous deployment')
@@ -213,6 +233,7 @@
on:restore={onRestore}
initFiles={files}
initRunnables={runnables}
initData={data}
{summary}
{newPath}
path={page.params.path ?? ''}