mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
feat: data table integrations for raw apps (#7436)
This commit is contained in:
@@ -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}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})) ?? []
|
||||
)
|
||||
}
|
||||
@@ -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 ?? ''}
|
||||
|
||||
Reference in New Issue
Block a user