diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 75d81e130a..2c344f307b 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -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) + )
- {#if dbSupportsSchemas} + {#if dbSelector} + {@render dbSelector()} + {/if} + {#if dbSupportsSchemas && !multiSelectMode} e.stopPropagation()} + onchange={() => toggleSchemaSelection(schemaKey)} + /> + + {/if} + {schemaKey} + {schemaTables.length} + + +
+ + {#each schemaTables as tableKey} + {@const isDisabled = isTableDisabled(schemaKey, tableKey)} + {@const isChecked = isTableSelected(schemaKey, tableKey) || isDisabled} + {@const isCurrentPreview = selected.schemaKey === schemaKey && selected.tableKey === tableKey} +
{ + 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) + } + }} + > + + e.stopPropagation()} + onchange={() => toggleTableSelection(schemaKey, tableKey)} + /> + + +

{tableKey}

+ + +
+ {/each} + + - {/each} + + New table + + {/each} + {:else} + + {#each filteredTableKeys as tableKey} + + {/each} + {/if} - + {#if !multiSelectMode} + + {/if}
{#if tableKey} @@ -225,3 +504,92 @@ /> + + { + newSchemaDialogOpen = false + newSchemaName = '' + }} +> + { + newSchemaDialogOpen = false + newSchemaName = '' + }} + title="Create a new schema" + > +
+
+ + { + 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} +

+ Schema "{sanitizedNewSchemaName}" already exists +

+ {:else} +

+ Only letters, numbers, and underscores are allowed. +

+ {/if} +
+
+ {#snippet actions()} + + {/snippet} +
+
diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte new file mode 100644 index 0000000000..57a768d414 --- /dev/null +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -0,0 +1,248 @@ + + + { + if (e.key === 'Escape') { + if (replResultData) { + replResultData = undefined + } + } + }} +/> + +{#if dbSchema && $workspaceStore && input} + {@const _input = input} + {@const dbType = getDbType(_input)} + + + + +
{ + // 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} + + {/key} + {/if} +
+ + 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} + /> +
+ {#if showRepl} + + { + replResultData = data + }} + placeholderTableName={sortArray( + Object.keys( + dbSchema?.schema[ + 'public' in dbSchema?.schema + ? 'public' + : 'dbo' in dbSchema?.schema + ? 'dbo' + : Object.keys(dbSchema?.schema ?? {})?.[0] + ] ?? {} + ) + )?.[0]} + /> + + {/if} +
+{:else} + + + + + +{/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index a88e390ff1..cebb7a349d 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -1,236 +1,147 @@ - { - if (e.key === 'Escape') { - if (replResultData) { - replResultData = undefined - } - } - }} -/> + { - 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)} - - - - -
{ - // 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} - - {/key} + {#if effectiveInput && $workspaceStore} + {#key selectedDatatable} + + {#snippet dbSelector()} + {#if isDatatableInput} + {#if datatables.loading} +
+ + Loading... +
+ {:else if datatables.current.length >= 1} + datatable, (v) => onChange?.(v, schema)} + placeholder="Select database" + size="sm" + /> +
+ +
+ Schema + `Datatable: ${s}`} + items={datatableItems} + bind:value={selectedDatatable} + placeholder="Select data table" + size="md" + /> + {/if} + {/snippet} + + {/key} + {:else} +
+ Select a data table to explore +
+ {/if} + + {#snippet actions()} + + + + + +
+{/snippet} + +{#snippet tableList()} + {#if dataTableRefs.length === 0} + No tables referenced yet + {:else} +
+ {#each sortedDatatableEntries as [datatableName, schemaGroups] (datatableName)} + {@const isDefaultDatatable = datatableName === defaultDatatable} + +
+ + {datatableName} + {#if isDefaultDatatable} + + + + {/if} +
+ + {#each sortSchemaEntries([...schemaGroups.entries()], datatableName) as [schemaName, items] (schemaName)} + {@const isDefaultSchema = + isDefaultDatatable && + (schemaName === defaultSchema || (schemaName === '' && !defaultSchema))} + + {#if schemaName} +
+ {schemaName} + {#if isDefaultSchema} + + + + {/if} +
+ {/if} + + + {#each items as { ref, index } (index)} + + +
onSelect?.(ref, index)} + > + + + {ref.table ?? '(all tables)'} + + +
+ {/each} + {/each} + {/each} +
+ {/if} +{/snippet} + +{#if standalone} +
+
+ Existing tables to use + {@render actionButtons()} +
+ {@render tableList()} +
+{:else} + + {#snippet action()} + {@render actionButtons()} + {/snippet} + + {@render tableList()} + +{/if} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 7851822856..1159458015 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -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 initRunnables: Record + /** 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 => { + 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 = {} + 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[]; 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 runnables: Record 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) } } @@ -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) }} >
diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index e5a7e539ca..7391d1d57f 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -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 files: Record | undefined + /** Data configuration including tables and creation policy */ + data: RawAppData jobs: string[] jobsById: Record 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() diff --git a/frontend/src/lib/components/raw_apps/RawAppHistoryManager.svelte.ts b/frontend/src/lib/components/raw_apps/RawAppHistoryManager.svelte.ts index d2a9c99464..4c32d37371 100644 --- a/frontend/src/lib/components/raw_apps/RawAppHistoryManager.svelte.ts +++ b/frontend/src/lib/components/raw_apps/RawAppHistoryManager.svelte.ts @@ -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 runnables: Record summary: string + data: RawAppData } /** @@ -55,6 +57,7 @@ export class RawAppHistoryManager { files: Record runnables: Record summary: string + data: RawAppData }) | undefined = undefined private isCreatingSnapshot = $state(false) @@ -108,14 +111,16 @@ export class RawAppHistoryManager { createSnapshot( files: Record, runnables: Record, - 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, runnables: Record, - 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, runnables: Record, 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 runnables: Record 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 } diff --git a/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte b/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte index d6b0c8f71f..70ddd256c5 100644 --- a/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppSidebar.svelte @@ -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 @@ -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(undefined) @@ -293,9 +330,21 @@
- - Coming soon - + dataTableDrawer?.openDrawer()} + onRemove={handleRemoveDataTable} + onSelect={handleSelectDataTable} + {onDefaultChange} + selectedIndex={selectedDataTableIndex} +/> + {#if historyManager && onHistorySelect && onManualSnapshot}
diff --git a/frontend/src/lib/components/raw_apps/dataTableRefUtils.ts b/frontend/src/lib/components/raw_apps/dataTableRefUtils.ts new file mode 100644 index 0000000000..89c727ee91 --- /dev/null +++ b/frontend/src/lib/components/raw_apps/dataTableRefUtils.ts @@ -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: /: or /
(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: /
+ return { datatable, schema: 'public', table: rest } + } + // Has colon: /:
+ const schema = rest.slice(0, colonIdx) + const table = rest.slice(colonIdx + 1) + return { datatable, schema, table } +} + +/** + * Format a DataTableRef object into a string + * Format: /:
or /
(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}` +} diff --git a/frontend/src/lib/components/raw_apps/datatableUtils.svelte.ts b/frontend/src/lib/components/raw_apps/datatableUtils.svelte.ts new file mode 100644 index 0000000000..2f842eea79 --- /dev/null +++ b/frontend/src/lib/components/raw_apps/datatableUtils.svelte.ts @@ -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([() => 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([() => 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 + })) ?? [] + ) +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b7bbb6218b..ee78419583 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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) { diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 13ff9e6e6d..f6fa559875 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -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(undefined) + let schemaMode = $state<'none' | 'new' | 'existing'>('new') + let selectedSchema = $state(undefined) + let newSchemaName = $state('') + let appSummary = $state('') + let initialPrompt = $state('') + let dataTableDrawer: RawAppDataTableDrawer | undefined = $state() + + // Pre-whitelisted tables for the app + let preWhitelistedTables = $state([]) + + // 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(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) + } + } {#if templatePicker} - -
- {#each templates as t} - + {/each} +
+ + + +
+

Data Configuration

+ + {#if hasNoDatatables} +
+ +
+ No datatables configured. + 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. +
-
{t.name}
- - {/each} + {:else} +
+ +
+ Default settings for new tables +
+
+ +
+ {/if} +
+ {#if newSchemaAlreadyExists} + Schema "{newSchemaName}" already exists + {/if} +
+
+
+ + +
+ +
+ + +
+ dataTableDrawer?.openDrawer()} + onRemove={(index) => { + preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) + }} + /> +
+ + {/if} + + + +
+

+ + Start with AI + (optional) +

+ + {#if !isAiEnabled} +
+ +
+ AI is not configured for this workspace. You can still create an app manually. +
+
+ {:else} +
+ +

+ Leave empty to start with a blank template, or describe your app to get AI assistance + right away. +

+
+ {/if} +
+ + +
+ + {#if isAiEnabled} + + {/if} +
{/if} @@ -185,9 +566,19 @@ }} initFiles={files} initRunnables={runnables} + initData={data} {policy} path={''} {summary} newApp /> {/key} + + { + preWhitelistedTables = [...preWhitelistedTables, ref] + }} +/> diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index edf7f21740..f80879bcf1 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -1,4 +1,6 @@