From 28fe5811de159fb22a293d65e98118dfd8e6a659 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 19 Feb 2025 14:58:58 +1000 Subject: [PATCH] Inline editor feature preview (#33541) * add edge function knowledge * deploy edge function mutaton * add edge function block with deploy * update function url * update tools * editor sql * add templates * use monaco editor * hook up templates * fixes * add behind feature flag * preview docs * move editor * initialPrompt * consolidate widgets * remove generic editor * move logic * fix ts * include schema metadata * Fix width of save snippet button * Set tooltip position * Use inlineLink component * Remove unnecessary z index * Lint * Lint import statements in EditorPanel * fixes * fix keyboard shortcut * show/hide results and invalidation * fix focus issues in widget * fixes * fix ts * Support cmd enter shortcut to run query in InlineEditor * Update InlineEditorPreview to use admonition * prompt updates * add discussion url to preview * schemas array * One last clean up --------- Co-authored-by: Joshen Lim --- .../FeaturePreview/FeaturePreviewContext.tsx | 13 + .../FeaturePreview/InlineEditorPreview.tsx | 33 ++ .../PolicyEditorPanel/RLSCodeEditor.tsx | 2 - .../PolicyTableRow/PolicyTableRow.utils.ts | 2 +- .../PolicyTableRow/PolicyTableRowHeader.tsx | 2 +- .../Triggers/TriggersList/TriggerList.tsx | 8 +- .../TriggersList/TriggerList.utils.ts | 4 + .../interfaces/SQLEditor/AskAIWidget.tsx | 81 ++-- .../interfaces/SQLEditor/InlineWidget.tsx | 97 +++-- .../interfaces/SQLEditor/SQLEditor.tsx | 77 ++-- .../SQLEditor/UtilityPanel/RunButton.tsx | 47 +++ .../SQLEditor/UtilityPanel/UtilityActions.tsx | 44 +- .../layouts/AppLayout/InlineEditorButton.tsx | 23 + .../LayoutHeader/LayoutHeader.tsx | 15 +- .../layouts/ProjectLayout/ProjectLayout.tsx | 15 +- .../ui/AIEditor/ResizableAIWidget.tsx | 88 ++++ apps/studio/components/ui/AIEditor/index.tsx | 352 ++++++++++++++++ .../components/ui/CodeEditor/CodeEditor.tsx | 2 +- .../components/ui/EditorPanel/EditorPanel.tsx | 396 ++++++++++++++++++ .../ui/QueryBlock/EditQueryButton.tsx | 37 +- .../components/ui/QueryBlock/QueryBlock.tsx | 65 +-- .../components/ui/SqlWarningAdmonition.tsx | 51 +++ apps/studio/lib/constants/index.ts | 2 + apps/studio/pages/api/ai/sql/complete.ts | 16 +- apps/studio/pages/api/ai/sql/generate-v3.ts | 2 +- apps/studio/pages/api/ai/sql/tools.ts | 3 +- .../pages/project/[ref]/auth/policies.tsx | 47 ++- .../project/[ref]/database/functions.tsx | 41 +- .../pages/project/[ref]/database/triggers.tsx | 37 +- .../img/previews/inline-editor-preview.png | Bin 0 -> 74160 bytes apps/studio/state/app-state.ts | 80 +++- .../components/ExpandingTextArea/index.tsx | 41 +- 32 files changed, 1471 insertions(+), 252 deletions(-) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx create mode 100644 apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx create mode 100644 apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx create mode 100644 apps/studio/components/ui/AIEditor/index.tsx create mode 100644 apps/studio/components/ui/EditorPanel/EditorPanel.tsx create mode 100644 apps/studio/components/ui/SqlWarningAdmonition.tsx create mode 100644 apps/studio/public/img/previews/inline-editor-preview.png diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 2d897e8b14e..7695b58f998 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -5,6 +5,7 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' import { APISidePanelPreview } from './APISidePanelPreview' import { CLSPreview } from './CLSPreview' +import InlineEditorPreview from './InlineEditorPreview' export const FEATURE_PREVIEWS = [ { @@ -21,6 +22,13 @@ export const FEATURE_PREVIEWS = [ discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', isNew: false, }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR, + name: 'Inline SQL Editor', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/33690', + isNew: true, + }, ] type FeaturePreviewContextType = { @@ -77,3 +85,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] } + +export const useIsInlineEditorEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx new file mode 100644 index 00000000000..24671a64cac --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx @@ -0,0 +1,33 @@ +import Image from 'next/image' + +import { BASE_PATH } from 'lib/constants' +import { Admonition } from 'ui-patterns' + +const InlineEditorPreview = () => { + return ( +
+ api-docs-side-panel-preview +

+ Access an inline SQL editor where you can write and run queries wherever you are in the + dashboard. Use the inline Assistant to generate or modify queries without leaving the + editor. +

+ + With the inline editor enabled, editing policies, triggers and database functions will all + be done using the editor. + +

+ You can access the inline editor by clicking the code editor icon in the top right corner of + your dashboard. +

+
+ ) +} + +export default InlineEditorPreview diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx index b36d37dead4..d67bd4af8db 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx @@ -5,7 +5,6 @@ import { MutableRefObject, useEffect, useRef } from 'react' import { cn } from 'ui' import { Markdown } from 'components/interfaces/Markdown' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { formatSql } from 'lib/formatSql' // [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard? @@ -50,7 +49,6 @@ export const RLSCodeEditor = ({ }: RLSCodeEditorProps) => { const hasValue = useRef() const monaco = useMonaco() - const { project } = useProjectContext() const placeholderId = `monaco-placeholder-${id}` const options: editor.IStandaloneEditorConstructionOptions = { diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts index 09c9b82e3c9..dcbcbc3a7c3 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts @@ -12,7 +12,7 @@ export const generatePolicyCreateSQL = (policy: PostgresPolicy) => { } return ` -CREATE POLICY "${policy.name}" +ALTER POLICY "${policy.name}" ON "${policy.schema}"."${policy.table}" AS ${policy.action} FOR ${policy.command} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 77204da34d0..ccd2a5fd7ae 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -117,7 +117,7 @@ const PolicyTableRowHeader = ({ onClick={() => { setAiAssistantPanel({ open: true, - initialInput: `Create a new policy for the ${table.schema} schema on the ${table.name} table that ...`, + initialInput: `Create and name a new policy for the ${table.schema} schema on the ${table.name} table that ...`, }) }} tooltip={{ diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index ee072d51856..eca9d8a0a25 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -139,7 +139,13 @@ const TriggerList = ({ + ) : ( +
+ + +
)} ) diff --git a/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx b/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx index 76201df71b9..7e3d2cd663a 100644 --- a/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx +++ b/apps/studio/components/interfaces/SQLEditor/InlineWidget.tsx @@ -1,5 +1,5 @@ import { editor } from 'monaco-editor' -import { PropsWithChildren, useEffect, useMemo } from 'react' +import { PropsWithChildren, useEffect, useMemo, useRef } from 'react' import { createPortal } from 'react-dom' export interface InlineWidgetProps { @@ -45,62 +45,83 @@ const InlineWidget = ({ }: PropsWithChildren) => { const lineNumber = beforeLineNumber ?? afterLineNumber const key = `${id}-${lineNumber.toString()}` - const containerElement = useMemo(() => document.createElement('div'), []) + const zoneIdRef = useRef() + const viewZoneRef = useRef<{ + top: number + height: number + heightInLines: number + }>({ top: 0, height: 0, heightInLines: heightInLines }) // Get the appropriate editor instance for diff editor const targetEditor = 'getModifiedEditor' in editor ? editor.getModifiedEditor() : editor - useEffect(() => { - let zoneId: string - let viewZoneTop = 0 - let viewZoneHeight = 0 + const recalculateLayout = () => { + const layoutInfo = targetEditor.getLayoutInfo() + if (!layoutInfo) { + return + } + + containerElement.style.left = `${layoutInfo.contentLeft}px` + containerElement.style.top = `${viewZoneRef.current.top}px` + containerElement.style.width = `${layoutInfo.width - layoutInfo.contentLeft - 20}px` + containerElement.style.height = `${viewZoneRef.current.height}px` + } + + const createViewZone = () => { + targetEditor.changeViewZones((accessor) => { + // Remove existing zone if it exists + if (zoneIdRef.current) { + accessor.removeZone(zoneIdRef.current) + } + + // Create new zone with current height + zoneIdRef.current = accessor.addZone({ + afterLineNumber: beforeLineNumber ?? afterLineNumber, + heightInLines: viewZoneRef.current.heightInLines, + domNode: document.createElement('div'), + onDomNodeTop: (top) => { + viewZoneRef.current.top = top + recalculateLayout() + }, + onComputedHeight: (height) => { + viewZoneRef.current.height = height + recalculateLayout() + }, + }) + }) + } + + // Initial setup of view zone and overlay widget + useEffect(() => { const overlayWidget: editor.IOverlayWidget = { getId: () => id, getDomNode: () => containerElement, getPosition: () => null, } - const recalculateLayout = () => { - const layoutInfo = targetEditor.getLayoutInfo() - - if (!layoutInfo) { - return - } - - containerElement.style.left = `${layoutInfo.contentLeft}px` - containerElement.style.top = `${viewZoneTop}px` - containerElement.style.width = `${layoutInfo.width - layoutInfo.contentLeft}px` - containerElement.style.height = `${viewZoneHeight}px` - } - - targetEditor.changeViewZones((accessor) => { - zoneId = accessor.addZone({ - afterLineNumber: beforeLineNumber ?? afterLineNumber, - heightInLines, - domNode: document.createElement('div'), - onDomNodeTop: (top) => { - viewZoneTop = top - recalculateLayout() - }, - onComputedHeight: (height) => { - viewZoneHeight = height - recalculateLayout() - }, - }) - - targetEditor.addOverlayWidget(overlayWidget) - }) + createViewZone() + targetEditor.addOverlayWidget(overlayWidget) // Remove the view zone & overlay widget on unmount return () => { targetEditor.changeViewZones((accessor) => { - accessor.removeZone(zoneId) + if (zoneIdRef.current) { + accessor.removeZone(zoneIdRef.current) + } targetEditor.removeOverlayWidget(overlayWidget) }) } - }, [targetEditor, id, beforeLineNumber, afterLineNumber, heightInLines, containerElement]) + }, [targetEditor, id, beforeLineNumber, afterLineNumber]) // Note: heightInLines removed from deps + + // Update view zone height when heightInLines changes + useEffect(() => { + if (heightInLines !== viewZoneRef.current.heightInLines) { + viewZoneRef.current.heightInLines = heightInLines + createViewZone() + } + }, [heightInLines]) return createPortal(children, containerElement, key) } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 8c9c6aeb680..62b818680c8 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -49,9 +49,8 @@ import { cn, } from 'ui' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' -import { AskAIWidget } from './AskAIWidget' +import ResizableAIWidget from 'components/ui/AIEditor/ResizableAIWidget' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' -import InlineWidget from './InlineWidget' import { RunQueryWarningModal } from './RunQueryWarningModal' import { ROWS_PER_PAGE_OPTIONS, @@ -687,29 +686,26 @@ export const SQLEditor = () => { }} /> {showWidget && ( - - { - handlePrompt(prompt, { - beforeSelection: promptState.beforeSelection, - selection: promptState.selection || defaultSqlDiff.modified, - afterSelection: promptState.afterSelection, - }) - }} - value={promptInput} - onChange={setPromptInput} - onAccept={acceptAiHandler} - onReject={discardAiHandler} - isDiffVisible={true} - isLoading={isCompletionLoading} - /> - + value={promptInput} + onChange={setPromptInput} + onSubmit={(prompt: string) => { + handlePrompt(prompt, { + beforeSelection: promptState.beforeSelection, + selection: promptState.selection || defaultSqlDiff.modified, + afterSelection: promptState.afterSelection, + }) + }} + onAccept={acceptAiHandler} + onReject={discardAiHandler} + onCancel={resetPrompt} + isDiffVisible={true} + isLoading={isCompletionLoading} + startLineNumber={Math.max(0, promptState.startLineNumber)} + endLineNumber={promptState.endLineNumber} + /> )} )} @@ -741,27 +737,24 @@ export const SQLEditor = () => { }} /> {editorRef.current && promptState.isOpen && !isDiffOpen && ( - - { - handlePrompt(prompt, { - beforeSelection: promptState.beforeSelection, - selection: promptState.selection, - afterSelection: promptState.afterSelection, - }) - }} - isDiffVisible={false} - isLoading={isCompletionLoading} - /> - + value={promptInput} + onChange={setPromptInput} + onSubmit={(prompt: string) => { + handlePrompt(prompt, { + beforeSelection: promptState.beforeSelection, + selection: promptState.selection, + afterSelection: promptState.afterSelection, + }) + }} + onCancel={resetPrompt} + isDiffVisible={false} + isLoading={isCompletionLoading} + startLineNumber={Math.max(0, promptState.startLineNumber)} + endLineNumber={promptState.endLineNumber} + /> )} {!promptState.isOpen && !editorRef.current?.getValue() && ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx new file mode 100644 index 00000000000..26bac02eec1 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx @@ -0,0 +1,47 @@ +import { detectOS } from 'lib/helpers' +import { Command, CornerDownLeft, Loader2 } from 'lucide-react' +import { Button } from 'ui' + +interface SqlRunButtonProps { + isDisabled?: boolean + isExecuting?: boolean + hasSelection?: boolean + className?: string + onClick: () => void +} + +export const SqlRunButton = ({ + isDisabled = false, + isExecuting = false, + hasSelection = false, + className, + onClick, +}: SqlRunButtonProps) => { + const os = detectOS() + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index fb07121d2a3..0affcf3395b 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -1,21 +1,10 @@ -import { - AlignLeft, - Check, - Command, - CornerDownLeft, - Heart, - Keyboard, - Loader2, - MoreVertical, -} from 'lucide-react' +import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react' import { toast } from 'sonner' import { useParams } from 'common' import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' import DatabaseSelector from 'components/ui/DatabaseSelector' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { detectOS } from 'lib/helpers' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' @@ -31,6 +20,7 @@ import { TooltipTrigger, cn, } from 'ui' +import { SqlRunButton } from './RunButton' import SavingIndicator from './SavingIndicator' export type UtilityActionsProps = { @@ -53,9 +43,6 @@ const UtilityActions = ({ const os = detectOS() const { ref } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() - const org = useSelectedOrganization() - - const { mutate: sendEvent } = useSendEventMutation() const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true) const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery( @@ -195,29 +182,12 @@ const UtilityActions = ({ onSelectId={onSelectDatabase} /> - + onClick={executeQuery} + /> diff --git a/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx new file mode 100644 index 00000000000..71ab33dbeda --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/InlineEditorButton.tsx @@ -0,0 +1,23 @@ +import { useAppStateSnapshot } from 'state/app-state' +import { Button } from 'ui' +import { SqlEditor } from 'icons' + +const InlineEditorButton = () => { + const { setEditorPanel, editorPanel } = useAppStateSnapshot() + + return ( + + ) +} + +export default InlineEditorButton diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 9b06985679c..7d3d26add7a 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -3,10 +3,12 @@ import { useRouter } from 'next/router' import { ReactNode, useMemo } from 'react' import { useParams } from 'common' +import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import Connect from 'components/interfaces/Connect/Connect' import AssistantButton from 'components/layouts/AppLayout/AssistantButton' import BranchDropdown from 'components/layouts/AppLayout/BranchDropdown' import EnableBranchingButton from 'components/layouts/AppLayout/EnableBranchingButton/EnableBranchingButton' +import InlineEditorButton from 'components/layouts/AppLayout/InlineEditorButton' import OrganizationDropdown from 'components/layouts/AppLayout/OrganizationDropdown' import ProjectDropdown from 'components/layouts/AppLayout/ProjectDropdown' import { getResourcesExceededLimitsOrg } from 'components/ui/OveragesBanner/OveragesBanner.utils' @@ -21,6 +23,7 @@ import BreadcrumbsView from './BreadcrumbsView' import { FeedbackDropdown } from './FeedbackDropdown' import HelpPopover from './HelpPopover' import NotificationsPopoverV2 from './NotificationsPopoverV2/NotificationsPopover' + const LayoutHeaderDivider = () => ( {!!projectRef && ( -
- +
+ {isInlineEditorEnabled && ( +
+ +
+ )} +
+ +
)}
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 1e83cb17376..09d1703ad90 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { AIAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' +import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' import AISettingsModal from 'components/ui/AISettingsModal' import { Loading } from 'components/ui/Loading' import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' @@ -83,8 +84,13 @@ const ProjectLayout = forwardRef - {isClient && aiAssistantPanel.open && ( + {isClient && (aiAssistantPanel.open || editorPanel.open) && ( <> - + {aiAssistantPanel.open && } + {editorPanel.open && } )} diff --git a/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx b/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx new file mode 100644 index 00000000000..21f49183481 --- /dev/null +++ b/apps/studio/components/ui/AIEditor/ResizableAIWidget.tsx @@ -0,0 +1,88 @@ +import { editor as monacoEditor } from 'monaco-editor' +import { useCallback, useEffect, useRef, useState } from 'react' +import InlineWidget from 'components/interfaces/SQLEditor/InlineWidget' +import { AskAIWidget } from 'components/interfaces/SQLEditor/AskAIWidget' + +interface ResizableAIWidgetProps { + editor: monacoEditor.IStandaloneCodeEditor | monacoEditor.IStandaloneDiffEditor + id: string + value: string + onChange: (value: string) => void + onSubmit: (prompt: string) => void + onAccept?: () => void + onReject?: () => void + onCancel?: () => void + isDiffVisible: boolean + isLoading?: boolean + startLineNumber: number + endLineNumber: number +} + +const LINE_HEIGHT = 20 // height of each line in pixels +const MIN_LINES = 3 // minimum number of lines to show + +const ResizableAIWidget = ({ + editor, + id, + value, + onChange, + onSubmit, + onAccept, + onReject, + onCancel, + isDiffVisible, + isLoading = false, + startLineNumber, + endLineNumber, +}: ResizableAIWidgetProps) => { + const containerRef = useRef(null) + const [heightInLines, setHeightInLines] = useState(MIN_LINES) + + const updateHeight = useCallback(() => { + if (containerRef.current) { + const height = containerRef.current.offsetHeight + const newHeightInLines = Math.max(MIN_LINES, Math.ceil(height / LINE_HEIGHT)) + setHeightInLines(newHeightInLines) + } + }, []) + + useEffect(() => { + // Update height on value change + updateHeight() + + // Set up resize observer to track height changes + const resizeObserver = new ResizeObserver(updateHeight) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => { + resizeObserver.disconnect() + } + }, [updateHeight]) + + return ( + +
+ +
+
+ ) +} + +export default ResizableAIWidget diff --git a/apps/studio/components/ui/AIEditor/index.tsx b/apps/studio/components/ui/AIEditor/index.tsx new file mode 100644 index 00000000000..67917a2a45a --- /dev/null +++ b/apps/studio/components/ui/AIEditor/index.tsx @@ -0,0 +1,352 @@ +import Editor, { DiffEditor, Monaco, OnMount } from '@monaco-editor/react' +import { useCompletion } from 'ai/react' +import { AnimatePresence, motion } from 'framer-motion' +import { Command } from 'lucide-react' +import { editor as monacoEditor } from 'monaco-editor' +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { constructHeaders } from 'data/fetchers' +import { detectOS } from 'lib/helpers' +import ResizableAIWidget from './ResizableAIWidget' + +interface AIEditorProps { + id?: string + language?: string + value?: string + defaultValue?: string + aiEndpoint?: string + aiMetadata?: { + projectRef?: string + connectionString?: string + includeSchemaMetadata?: boolean + } + initialPrompt?: string + readOnly?: boolean + className?: string + options?: monacoEditor.IStandaloneEditorConstructionOptions + onChange?: (value: string) => void + executeQuery?: () => void +} + +// [Joshen] This has overlap with components/interfaces/SQLEditor/MonacoEditor +// Can we try to de-dupe accordingly? Perhaps the SQL Editor could use this AIEditor +// We have a tendency to create multiple versions of the monaco editor like RLSCodeEditor +// so hoping to prevent that from snowballing +const AIEditor = ({ + language = 'javascript', + value, + defaultValue = '', + aiEndpoint, + aiMetadata, + initialPrompt, + readOnly = false, + className = '', + options = {}, + onChange, + executeQuery, +}: AIEditorProps) => { + const os = detectOS() + const editorRef = useRef(null) + const diffEditorRef = useRef(null) + + const executeQueryRef = useRef(executeQuery) + executeQueryRef.current = executeQuery + + const [currentValue, setCurrentValue] = useState(value || defaultValue) + const [isDiffMode, setIsDiffMode] = useState(false) + const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false) + const [diffValue, setDiffValue] = useState({ original: '', modified: '' }) + const [promptState, setPromptState] = useState({ + isOpen: Boolean(initialPrompt), + selection: '', + beforeSelection: '', + afterSelection: '', + startLineNumber: 0, + endLineNumber: 0, + }) + const [promptInput, setPromptInput] = useState(initialPrompt || '') + + const { + complete, + completion, + isLoading: isCompletionLoading, + setCompletion, + } = useCompletion({ + api: aiEndpoint || '', + body: aiMetadata, + onResponse: (response) => { + if (!response.ok) throw new Error('Failed to generate completion') + }, + onError: (error) => { + toast.error(`Failed to generate: ${error.message}`) + }, + }) + + const handleReset = useCallback(() => { + setCompletion('') + setIsDiffMode(false) + setPromptState((prev) => ({ ...prev, isOpen: false })) + setPromptInput('') + editorRef.current?.focus() + }, [setCompletion]) + + const handleAcceptDiff = useCallback(() => { + if (diffValue.modified) { + const newValue = diffValue.modified + setCurrentValue(newValue) + onChange?.(newValue) + handleReset() + } + }, [diffValue.modified, onChange, handleReset]) + + const handleRejectDiff = () => { + handleReset() + } + + const handleEditorOnMount: OnMount = ( + editor: monacoEditor.IStandaloneCodeEditor, + monaco: Monaco + ) => { + editorRef.current = editor + // Set prompt state to open if promptInput exists + if (promptInput) { + const model = editor.getModel() + if (model) { + const lineCount = model.getLineCount() + setPromptState({ + isOpen: true, + selection: model.getValue(), + beforeSelection: '', + afterSelection: '', + startLineNumber: 1, + endLineNumber: lineCount, + }) + } + } + + if (!!executeQueryRef.current) { + editor.addAction({ + id: 'run-query', + label: 'Run Query', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], + contextMenuGroupId: 'operation', + contextMenuOrder: 0, + run: () => executeQueryRef.current?.(), + }) + } + + editor.addAction({ + id: 'generate-ai', + label: 'Generate with AI', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK], + run: () => { + const selection = editor.getSelection() + const model = editor.getModel() + if (!model || !selection) return + + const allLines = model.getLinesContent() + const startLineIndex = selection.startLineNumber - 1 + const endLineIndex = selection.endLineNumber + + const beforeSelection = allLines.slice(0, startLineIndex).join('\n') + '\n' + const selectedText = allLines.slice(startLineIndex, endLineIndex).join('\n') + const afterSelection = '\n' + allLines.slice(endLineIndex).join('\n') + + setPromptState({ + isOpen: true, + selection: selectedText, + beforeSelection, + afterSelection, + startLineNumber: selection?.startLineNumber ?? 0, + endLineNumber: selection?.endLineNumber ?? 0, + }) + }, + }) + } + + const handlePrompt = async ( + prompt: string, + context: { + beforeSelection: string + selection: string + afterSelection: string + } + ) => { + try { + setPromptState((prev) => ({ + ...prev, + selection: context.selection, + beforeSelection: context.beforeSelection, + afterSelection: context.afterSelection, + })) + + const headerData = await constructHeaders() + await complete(prompt, { + headers: { Authorization: headerData.get('Authorization') ?? '' }, + body: { + ...aiMetadata, + completionMetadata: { + textBeforeCursor: context.beforeSelection, + textAfterCursor: context.afterSelection, + language, + prompt, + selection: context.selection, + }, + }, + }) + } catch (error) { + setPromptState((prev) => ({ ...prev, isOpen: false })) + } + } + + const defaultOptions: monacoEditor.IStandaloneEditorConstructionOptions = { + tabSize: 2, + fontSize: 13, + readOnly, + minimap: { enabled: false }, + wordWrap: 'on', + lineNumbers: 'on', + folding: false, + padding: { top: 4 }, + lineNumbersMinChars: 3, + ...options, + } + + useEffect(() => { + setCurrentValue(value || defaultValue) + }, [value, defaultValue]) + + useEffect(() => { + if (!isDiffMode) { + setIsDiffEditorMounted(false) + } + }, [isDiffMode]) + + useEffect(() => { + if (!completion) { + setIsDiffMode(false) + return + } + + const original = + promptState.beforeSelection + promptState.selection + promptState.afterSelection + const modified = promptState.beforeSelection + completion + promptState.afterSelection + + setDiffValue({ original, modified }) + setIsDiffMode(true) + }, [completion, promptState.beforeSelection, promptState.selection, promptState.afterSelection]) + + useEffect(() => { + const handleKeyboard = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleReset() + } else if ( + event.key === 'Enter' && + (os === 'macos' ? event.metaKey : event.ctrlKey) && + isDiffMode + ) { + event.preventDefault() + handleAcceptDiff() + } + } + + window.addEventListener('keydown', handleKeyboard) + return () => window.removeEventListener('keydown', handleKeyboard) + }, [os, isDiffMode, handleAcceptDiff, handleReset]) + + return ( +
+ {isDiffMode ? ( +
+ { + diffEditorRef.current = editor + setIsDiffEditorMounted(true) + }} + options={{ + ...defaultOptions, + renderSideBySide: false, + }} + /> + {isDiffEditorMounted && ( + { + handlePrompt(prompt, { + beforeSelection: promptState.beforeSelection, + selection: promptState.selection || diffValue.modified, + afterSelection: promptState.afterSelection, + }) + }} + onAccept={handleAcceptDiff} + onReject={handleRejectDiff} + onCancel={handleReset} + isDiffVisible={true} + isLoading={isCompletionLoading} + startLineNumber={Math.max(0, promptState.startLineNumber)} + endLineNumber={promptState.endLineNumber} + /> + )} +
+ ) : ( +
+ { + const newValue = value || '' + setCurrentValue(newValue) + onChange?.(newValue) + }} + onMount={handleEditorOnMount} + className={className} + /> + {promptState.isOpen && editorRef.current && ( + { + handlePrompt(prompt, { + beforeSelection: promptState.beforeSelection, + selection: promptState.selection, + afterSelection: promptState.afterSelection, + }) + }} + onCancel={handleReset} + isDiffVisible={false} + isLoading={isCompletionLoading} + startLineNumber={Math.max(0, promptState.startLineNumber)} + endLineNumber={promptState.endLineNumber} + /> + )} + + {!promptState.isOpen && !currentValue && aiEndpoint && ( + + Hit {os === 'macos' ? : `CTRL+`}K to edit with the Assistant + + )} + +
+ )} +
+ ) +} + +export default AIEditor diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index 97420bf87c2..9b5a8eb2c1a 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -22,7 +22,7 @@ const DEFAULT_ACTIONS = { interface CodeEditorProps { id: string - language: 'pgsql' | 'json' | 'html' | undefined + language: 'pgsql' | 'json' | 'html' | 'typescript' | undefined autofocus?: boolean defaultValue?: string isReadOnly?: boolean diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx new file mode 100644 index 00000000000..e5a8daae887 --- /dev/null +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -0,0 +1,396 @@ +import { debounce } from 'lodash' +import { Book, Save, X } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import { + createSqlSnippetSkeletonV2, + suffixWithLimit, +} from 'components/interfaces/SQLEditor/SQLEditor.utils' +import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' +import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton' +import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import { useAppStateSnapshot } from 'state/app-state' +import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' +import { AiIconAnimation, Button, cn, Input_Shadcn_, SQL_ICON } from 'ui' +import { Admonition } from 'ui-patterns' +import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils' +import AIEditor from '../AIEditor' +import { ButtonTooltip } from '../ButtonTooltip' +import { InlineLink } from '../InlineLink' +import SqlWarningAdmonition from '../SqlWarningAdmonition' + +interface EditorPanelProps { + onChange?: (value: string) => void +} + +export const EditorPanel = ({ onChange }: EditorPanelProps) => { + const { ref } = useParams() + const project = useSelectedProject() + const { editorPanel, setEditorPanel, setAiAssistantPanel } = useAppStateSnapshot() + const { profile } = useProfile() + const snapV2 = useSqlEditorV2StateSnapshot() + const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() + const isOptedInToAI = useOrgOptedIntoAi() + const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM + + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState() + const [results, setResults] = useState(undefined) + const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>() + const [currentValue, setCurrentValue] = useState(editorPanel.initialValue || '') + const [showTemplates, setShowTemplates] = useState(false) + const [templateSearch, setTemplateSearch] = useState('') + const [savedCode, setSavedCode] = useState('') + const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false) + const [showResults, setShowResults] = useState(true) + + const errorHeader = error?.formattedError?.split('\n')?.filter((x: string) => x.length > 0)?.[0] + const errorContent = + error?.formattedError + ?.split('\n') + ?.filter((x: string) => x.length > 0) + ?.slice(1) ?? [] + + const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: async (res) => { + setResults(res.result) + }, + onError: (error) => { + setError(error) + setResults([]) + }, + }) + + const handleChat = () => { + setAiAssistantPanel({ + open: true, + sqlSnippets: currentValue ? [currentValue] : [], + initialInput: 'Help me understand and improve this SQL query...', + suggestions: { + title: + 'I can help you understand and improve your SQL query. Here are a few example prompts to get you started:', + prompts: [ + 'Explain what this query does...', + 'Help me optimize this query...', + 'Show me how to add more conditions...', + 'Help me join this with another table...', + ], + }, + }) + } + + const onExecuteSql = (skipValidation = false) => { + setError(undefined) + setShowWarning(undefined) + + if (currentValue.length === 0) return + + if (!skipValidation) { + const isReadOnlySelectSQL = isReadOnlySelect(currentValue) + if (!isReadOnlySelectSQL) { + const hasUnknownFunctions = containsUnknownFunction(currentValue) + setShowWarning(hasUnknownFunctions ? 'hasUnknownFunctions' : 'hasWriteOperation') + return + } + } + + if (editorPanel.onSave) { + editorPanel.onSave(currentValue) + } + + executeSql({ + sql: suffixWithLimit(currentValue, 100), + projectRef: project?.ref, + connectionString: project?.connectionString, + handleError: (error) => { + throw error + }, + contextualInvalidation: true, + }) + } + + const handleChange = (value: string) => { + setCurrentValue(value) + onChange?.(value) + } + + const onSelectTemplate = (content: string) => { + handleChange(content) + setSavedCode(content) + setShowTemplates(false) + } + + // Create a debounced version of the revert code function + const debouncedRevertCode = useCallback( + debounce(() => { + setIsPreviewingTemplate(false) + handleChange(savedCode) + }, 300), + [savedCode] + ) + + const handleTemplateMouseEnter = (templateContent: string) => { + // Cancel any pending revert + debouncedRevertCode.cancel() + + if (!isPreviewingTemplate) { + setSavedCode(currentValue) + } + setIsPreviewingTemplate(true) + handleChange(templateContent) + } + + const handleTemplateMouseLeave = () => { + if (isPreviewingTemplate) { + debouncedRevertCode() + } + } + + // Cleanup debounce on unmount + useEffect(() => { + return () => { + debouncedRevertCode.cancel() + } + }, [debouncedRevertCode]) + + useEffect(() => { + if (editorPanel.initialValue !== undefined && editorPanel.initialValue !== currentValue) { + setCurrentValue(editorPanel.initialValue) + } + }, [editorPanel.initialValue]) + + useEffect(() => { + if (editorPanel.initialValue !== currentValue) { + setEditorPanel({ + initialValue: currentValue, + }) + } + }, [currentValue, setEditorPanel]) + + return ( +
+
+ SQL Editor +
+ + } + onClick={async () => { + if (!ref) return console.error('Project ref is required') + if (!project) return console.error('Project is required') + if (!profile) return console.error('Profile is required') + + try { + setIsSaving(true) + const { title: name } = await generateSqlTitle({ sql: currentValue }) + const snippet = createSqlSnippetSkeletonV2({ + id: uuidv4(), + name, + sql: currentValue, + owner_id: profile.id, + project_id: project.id, + }) + snapV2.addSnippet({ projectRef: ref, snippet }) + snapV2.addNeedsSaving(snippet.id) + toast.success( +
+ Saved snippet! View it{' '} + here +
+ ) + } catch (error: any) { + toast.error(`Failed to create new query: ${error.message}`) + } finally { + setIsSaving(false) + } + }} + /> + +
+
+ +
+
+ +
+ + {error !== undefined && ( +
+ + {errorContent.length > 0 ? ( + errorContent.map((errorText: string, i: number) => ( +
+                        {errorText}
+                      
+ )) + ) : ( +

{error.error}

+ )} +
+ } + /> +
+ )} + + {showWarning && ( + setShowWarning(undefined)} + onConfirm={() => { + setShowWarning(undefined) + onExecuteSql(true) + }} + /> + )} + + {results !== undefined && results.length > 0 && ( +
+ {showResults && ( +
+ +
+ )} +

+ + {results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`} + + +

+
+ )} + {results !== undefined && results.length === 0 && ( +
+

+ Success. No rows returned. +

+
+ )} + + {showTemplates && editorPanel.templates && ( +
+
+ setTemplateSearch(e.target.value)} + /> +
+
+ {editorPanel.templates + ?.filter((template) => { + const searchLower = templateSearch.toLowerCase() + return ( + template.name.toLowerCase().includes(searchLower) || + template.description.toLowerCase().includes(searchLower) + ) + }) + ?.map((template, i) => ( +
onSelectTemplate(template.content)} + onMouseEnter={() => handleTemplateMouseEnter(template.content)} + onMouseLeave={handleTemplateMouseLeave} + > + +
+

{template.name}

+

{template.description}

+
+
+ ))} +
+
+ )} +
+ + +
+
+ + ) +} + +export default EditorPanel diff --git a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx index 28f6c6cae98..af75ce53b02 100644 --- a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx +++ b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx @@ -10,6 +10,7 @@ import Link from 'next/link' import { ComponentProps } from 'react' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -18,21 +19,32 @@ import { } from 'ui' import { ButtonTooltip } from '../ButtonTooltip' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useAppStateSnapshot } from 'state/app-state' +import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' interface EditQueryButtonProps { id?: string title: string sql?: string + className?: string + type?: 'default' | 'text' } -export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => { +export const EditQueryButton = ({ + id, + sql, + title, + className, + type = 'text', +}: EditQueryButtonProps) => { const router = useRouter() const { ref } = useParams() const { newQuery } = useNewQuery() const sqlEditorSnap = useSqlEditorV2StateSnapshot() - + const { setEditorPanel } = useAppStateSnapshot() const isInSQLEditor = router.pathname.includes('/sql') const isInNewSnippet = router.pathname.endsWith('/sql') + const isInlineEditorEnabled = useIsInlineEditorEnabled() const tooltip: { content: ComponentProps & { text: string } } = { content: { side: 'bottom', text: 'Edit in SQL Editor' }, } @@ -54,9 +66,9 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => { return ( } tooltip={tooltip} > @@ -67,12 +79,19 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => { return !isInSQLEditor || isInNewSnippet ? ( } onClick={() => { - handleEditInSQLEditor() + if (isInlineEditorEnabled) { + setEditorPanel({ + open: true, + initialValue: sql, + }) + } else { + handleEditInSQLEditor() + } sendEvent({ action: TelemetryActions.ASSISTANT_EDIT_IN_SQL_EDITOR_CLICKED, properties: { @@ -88,10 +107,10 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => { } tooltip={!!sql ? tooltip : { content: { side: 'bottom', text: undefined } }} /> diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index 8a69fb168a2..3ce07d91e18 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -28,6 +28,7 @@ import { BlockViewConfiguration } from './BlockViewConfiguration' import { EditQueryButton } from './EditQueryButton' import { ParametersPopover } from './ParametersPopover' import { getCumulativeResults } from './QueryBlock.utils' +import SqlWarningAdmonition from '../SqlWarningAdmonition' export const DEFAULT_CHART_CONFIG: ChartConfig = { type: 'bar', @@ -282,51 +283,25 @@ export const QueryBlock = ({ } > {!!showWarning && ( - -

- {showWarning === 'hasWriteOperation' - ? 'This query contains write operations.' - : 'This query involves running a function.'}{' '} - Are you sure you want to execute it? -

-

- Make sure you are not accidentally removing something important. -

-
- - -
-
+ setShowWarning(undefined)} + onConfirm={() => { + // [Joshen] This is for when we introduced the concept of parameters into our reports + // const processedSql = processParameterizedSql(sql!, combinedParameterValues) + if (sql) { + setShowWarning(undefined) + execute({ + projectRef: ref, + connectionString: project?.connectionString, + sql, + }) + onRunQuery?.('mutation') + } + }} + disabled={!sql} + /> )} {isExecuting && queryResult === undefined && ( diff --git a/apps/studio/components/ui/SqlWarningAdmonition.tsx b/apps/studio/components/ui/SqlWarningAdmonition.tsx new file mode 100644 index 00000000000..bf5ef1421da --- /dev/null +++ b/apps/studio/components/ui/SqlWarningAdmonition.tsx @@ -0,0 +1,51 @@ +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' + +export interface SqlWarningAdmonitionProps { + warningType: 'hasWriteOperation' | 'hasUnknownFunctions' + onCancel: () => void + onConfirm: () => void + disabled?: boolean + className?: string +} + +const SqlWarningAdmonition = ({ + warningType, + onCancel, + onConfirm, + disabled = false, + className, +}: SqlWarningAdmonitionProps) => { + return ( + +

+ {warningType === 'hasWriteOperation' + ? 'This query contains write operations.' + : 'This query involves running a function.'}{' '} + Are you sure you want to execute it? +

+

+ Make sure you are not accidentally removing something important. +

+
+ + +
+
+ ) +} + +export default SqlWarningAdmonition diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 5f37947e323..de927eebe22 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -41,10 +41,12 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75 export const LOCAL_STORAGE_KEYS = { AI_ASSISTANT_STATE: 'supabase-ai-assistant-state', + EDITOR_PANEL_STATE: 'supabase-editor-panel-state', UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout', UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_CLS: 'supabase-ui-cls', + UI_PREVIEW_INLINE_EDITOR: 'supabase-ui-preview-inline-editor', UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown', SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged', diff --git a/apps/studio/pages/api/ai/sql/complete.ts b/apps/studio/pages/api/ai/sql/complete.ts index 29d6d63bf49..50ef9b1910e 100644 --- a/apps/studio/pages/api/ai/sql/complete.ts +++ b/apps/studio/pages/api/ai/sql/complete.ts @@ -68,6 +68,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) # You generate and debug SQL The generated SQL (must be valid SQL), and must adhere to the following: + - Always retrieve public schema information first - Always use double apostrophe in SQL strings (eg. 'Night''s watch') - Always use semicolons - Use vector(384) data type for any embedding/vector related query @@ -96,8 +97,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) # You write row level security policies. - Your purpose is to generate a policy with the constraints given by the user. - - First, use getSchema to retrieve more information about a schema or schemas that will contain policies, usually the public schema. + Your purpose is to generate a policy with the constraints given by the user using the getRlsKnowledge tool. + - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema. - Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool . - Then write new policies or update existing policies based on the prompt - When asked to suggest policies, either alter existing policies or add new ones to the public schema. @@ -126,10 +127,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) Instructions: 1. Only modify the selected text based on this prompt: ${prompt} - 2. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed. - 3. Do not wrap in code blocks or markdown - 4. You can respond with one word or multiple words - 5. Ensure the modified text flows naturally within the current line + 2. Get schema tables information using the getSchemaTables tool + 3. Get existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool + 4. Write new policies or update existing policies based on the prompt + 5. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed. + 6. Do not wrap in code blocks or markdown + 7. You can respond with one word or multiple words + 8. Ensure the modified text flows naturally within the current line 6. Avoid duplicating SQL keywords (SELECT, FROM, WHERE, etc) when considering the full statement 7. If there is no surrounding context (before or after), make sure your response is a complete valid SQL statement that can be run and resolves the prompt. diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts index d0b280c4e4a..71187593fe6 100644 --- a/apps/studio/pages/api/ai/sql/generate-v3.ts +++ b/apps/studio/pages/api/ai/sql/generate-v3.ts @@ -105,7 +105,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { # You write row level security policies. Your purpose is to generate a policy with the constraints given by the user. - - First, use getSchema to retrieve more information about a schema or schemas that will contain policies, usually the public schema. + - First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema. - Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool . - Then write new policies or update existing policies based on the prompt - When asked to suggest policies, either alter existing policies or add new ones to the public schema. diff --git a/apps/studio/pages/api/ai/sql/tools.ts b/apps/studio/pages/api/ai/sql/tools.ts index a0da432e704..8473a7198ff 100644 --- a/apps/studio/pages/api/ai/sql/tools.ts +++ b/apps/studio/pages/api/ai/sql/tools.ts @@ -28,7 +28,7 @@ export const getTools = ({ } return { - getSchema: tool({ + getSchemaTables: tool({ description: 'Get more information about one or more schemas', parameters: z.object({ schemas: z.array(z.string()).describe('The schema names to get the definitions for'), @@ -85,6 +85,7 @@ export const getTools = ({ { projectRef, connectionString, + schema: schemas?.join(','), }, undefined, headers diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index 4e52ee86078..4d42fee486c 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -6,6 +6,8 @@ import { useState } from 'react' import { PolicyEditorPanel } from 'components/interfaces/Auth/Policies/PolicyEditorPanel' import Policies from 'components/interfaces/Auth/Policies/Policies' +import { generatePolicyCreateSQL } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' +import { getGeneralPolicyTemplates } from 'components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' @@ -22,6 +24,8 @@ import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import type { NextPageWithLayout } from 'types' import { Input } from 'ui' import DefaultLayout from 'components/layouts/DefaultLayout' +import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useAppStateSnapshot } from 'state/app-state' /** * Filter tables by table name and policy name @@ -65,6 +69,8 @@ const AuthPoliciesPage: NextPageWithLayout = () => { }>() const { schema = 'public', search: searchString = '' } = params const { project } = useProjectContext() + const { setEditorPanel } = useAppStateSnapshot() + const isInlineEditorEnabled = useIsInlineEditorEnabled() const [selectedTable, setSelectedTable] = useState() const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false) @@ -147,12 +153,45 @@ const AuthPoliciesPage: NextPageWithLayout = () => { hasTables={tables.length > 0} isLocked={isLocked} onSelectCreatePolicy={(table: string) => { - setSelectedTable(table) - setShowPolicyAiEditor(true) + if (isInlineEditorEnabled) { + setEditorPanel({ + open: true, + initialValue: `create policy "replace_with_policy_name" + on ${schema}.${table} + for select + to authenticated + using ( + true -- Write your policy condition here +);`, + label: `Create new RLS policy on "${table}"`, + saveLabel: 'Create policy', + initialPrompt: `Create and name a entirely new RLS policy for the "${table}" table in the ${schema} schema. The policy should...`, + }) + } else { + setSelectedTable(table) + setShowPolicyAiEditor(true) + } }} onSelectEditPolicy={(policy) => { - setSelectedPolicyToEdit(policy) - setShowPolicyAiEditor(true) + if (isInlineEditorEnabled) { + const sql = generatePolicyCreateSQL(policy) + const templates = getGeneralPolicyTemplates(policy.schema, policy.table) + setEditorPanel({ + open: true, + initialValue: sql, + label: `Edit policy "${policy.name}"`, + saveLabel: 'Update policy', + templates: templates.map((template) => ({ + name: template.templateName, + description: template.description, + content: template.statement, + })), + initialPrompt: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`, + }) + } else { + setSelectedPolicyToEdit(policy) + setShowPolicyAiEditor(true) + } }} /> )} diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index 0a4b183bc82..c51ff1136e6 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -11,23 +11,54 @@ import { DatabaseFunction } from 'data/database-functions/database-functions-que import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' import DefaultLayout from 'components/layouts/DefaultLayout' +import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useAppStateSnapshot } from 'state/app-state' const FunctionsPage: NextPageWithLayout = () => { const [selectedFunction, setSelectedFunction] = useState() const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false) const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false) + const { setEditorPanel } = useAppStateSnapshot() + const isInlineEditorEnabled = useIsInlineEditorEnabled() const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions') const isPermissionsLoaded = usePermissionsLoaded() const createFunction = () => { - setSelectedFunction(undefined) - setShowCreateFunctionForm(true) + if (isInlineEditorEnabled) { + setEditorPanel({ + open: true, + initialValue: `create function function_name() +returns void +language plpgsql +as $$ +begin + -- Write your function logic here +end; +$$;`, + label: 'Create new database function', + saveLabel: 'Create function', + initialPrompt: 'Create a new database function that...', + }) + } else { + setSelectedFunction(undefined) + setShowCreateFunctionForm(true) + } } - const editFunction = (fn: any) => { - setSelectedFunction(fn) - setShowCreateFunctionForm(true) + const editFunction = (fn: DatabaseFunction) => { + if (isInlineEditorEnabled) { + setEditorPanel({ + open: true, + initialValue: fn.complete_statement, + label: `Edit function "${fn.name}"`, + saveLabel: 'Update function', + initialPrompt: `Update the database function "${fn.name}" to...`, + }) + } else { + setSelectedFunction(fn) + setShowCreateFunctionForm(true) + } } const deleteFunction = (fn: any) => { diff --git a/apps/studio/pages/project/[ref]/database/triggers.tsx b/apps/studio/pages/project/[ref]/database/triggers.tsx index ecb8a6e03f8..8d331386990 100644 --- a/apps/studio/pages/project/[ref]/database/triggers.tsx +++ b/apps/studio/pages/project/[ref]/database/triggers.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { CreateTrigger, DeleteTrigger } from 'components/interfaces/Database' import TriggersList from 'components/interfaces/Database/Triggers/TriggersList/TriggersList' +import { generateTriggerCreateSQL } from 'components/interfaces/Database/Triggers/TriggersList/TriggerList.utils' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { FormHeader } from 'components/ui/Forms/FormHeader' @@ -10,23 +11,51 @@ import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' import DefaultLayout from 'components/layouts/DefaultLayout' +import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useAppStateSnapshot } from 'state/app-state' const TriggersPage: NextPageWithLayout = () => { const [selectedTrigger, setSelectedTrigger] = useState() const [showCreateTriggerForm, setShowCreateTriggerForm] = useState(false) const [showDeleteTriggerForm, setShowDeleteTriggerForm] = useState(false) + const { setEditorPanel } = useAppStateSnapshot() + const isInlineEditorEnabled = useIsInlineEditorEnabled() const canReadTriggers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') const isPermissionsLoaded = usePermissionsLoaded() const createTrigger = () => { - setSelectedTrigger(undefined) - setShowCreateTriggerForm(true) + if (isInlineEditorEnabled) { + setEditorPanel({ + open: true, + initialValue: `create trigger trigger_name +after insert or update or delete on table_name +for each row +execute function function_name();`, + label: 'Create new database trigger', + saveLabel: 'Create trigger', + initialPrompt: 'Create a new database trigger that...', + }) + } else { + setSelectedTrigger(undefined) + setShowCreateTriggerForm(true) + } } const editTrigger = (trigger: any) => { - setSelectedTrigger(trigger) - setShowCreateTriggerForm(true) + if (isInlineEditorEnabled) { + const sql = generateTriggerCreateSQL(trigger) + setEditorPanel({ + open: true, + initialValue: sql, + label: `Edit trigger "${trigger.name}"`, + saveLabel: 'Update trigger', + initialPrompt: `Update the database trigger "${trigger.name}" to...`, + }) + } else { + setSelectedTrigger(trigger) + setShowCreateTriggerForm(true) + } } const deleteTrigger = (trigger: any) => { diff --git a/apps/studio/public/img/previews/inline-editor-preview.png b/apps/studio/public/img/previews/inline-editor-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..13e749e97709711338ddc97df64f2cc1b39bfa13 GIT binary patch literal 74160 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%0A}u9==Yk|dnWU_`cyVA!PmS^3Rvpj@a{HN z1S;Z3%=GeJLybANc#8C22gUw?GMAe;6(UJDe(AWJ?2bLIf*_9d6W!nticqlS5GV5` zhUT5F{8MV2?3rZ57G)-%Qp}LU6@H=EbMMZK)eF~G6OHqq8cHj-rat#3W&Od;vv~lE5ZNu9hT@+3_9l-@iXd0SA8g!n{hs@8zT~+)$xXw=Q~u zPI-OX|E*FzYVGqu06qGtuhn@&XI&)6L77I#l~4*p#qW%zLJb#fE?%1w_z+ zNYH_+ha(IoN~cRkz9=ujGq*@2zWt%AL*Mvy*JXW#V4Fm_ybqOy5@Mk>2Ts;CVP>=` zt#F~?_Kof!`nnd5oJBSykCf}Ek=#NS(Mfqk-Bn87RkCoRE65@hi!%*~Tactye-I(H zszaiV8ZXV5;A(9f=P6@uKOQN$QtbFr#Y4ZSzb~F_8zK01y9ESRZ)^5>+{%rsRUK-X z?zqGB&VJw^I+s`qN4&N$hJTjS*g)yZV~}>G-(SlxaOpKQvvZQ1h*H`H;e0;c#X*y$dhVkipj;+7DIwgJXrt6^K;EgL1tV z+C+VQ@TEu+zfve&YzxH`J-OZuiP=+)67B#%!o}j`P@YBM3VYE8%o&NCMbTgOtxvO$+V98hVRwGgf);a|UN*D5k%%jmrqi%Xn6h)ECJn z6K3N=k=Nd2#cY|EQCe1G7kz!(;j)uL`Kjm!aM>-tjA=LSQ13iw4KMrf4W%Be3+aaU zg`<-#C^!{fQ^kVP=3GgT_#y7O@9+{bIZe%ED}=MJZsI7bYHJ|-bj2ioIo0FzBaC11 zv}BXHUN{FQeeR!czkDX>eY!5VGsWdWwfcUgSyZUc@XPi^*F_xGUsLMmHHuY*@)^;p zF1CWPjDZ$fox24WA0i4GHNi|LlS!VvwFG{RjXU~D`^jzH4{coZ*Y}=>K2|LO`%zQ> zM6p&yaVHH*yRX2?)XJsj8R;#wMR!|UKx!4T_S!B;g5I3pty#_GV}ceCm~_~$1cq^R zK`_yya`^&lcHua@VF`jA6@;uBOa%v{&L0-=WVZ4-7b#FJe`*&#sc**B6Tx7)tq&L< ze9dExxc)AqPVvOUCd`T9jgrnvO89!wHwz8^nvPuxoRkhVkKBTgLl!a&-3l_y-=>Xbv7=MLqDSX>Sb7yAloko?yano-2@!6DvgkwQPziYh?csZ9F9qSMgXqh!g0l5K0gLprzxon0?GJocQsbMM^B)}9wM)@Fc4!!Y z1IXktNNg>bId+c|2BVt)LMCL0VbK#L435!4^%v3ITC`u?i^zY75A2w`4HhE%9$Qw* z2Ys;U%<$N^su}gQ&5rwfT^rXQnSDTuy4Dh@nJu}&q){H1lAgnh@bA53IaL#uQ=W_6 zdBuQc`yTO!?@EC$ZgMsa3f`f>okXN7pqDJ$F%HC`ata^mzPSlnN3Emnni#(=mE}ac z@)i(6R!383NbZaUfzgXTk=c}YZ3$)pkp)UHSOPp3Aw%>Ut)P<_r89Utc7Xr8hL)QN ziF)~RZer{o*V)c{L+II4d577`wb0qprloiG_*#8k*>-C&nH1q-j$3>pR!q+3UqucN zJ(p)mj#5oU3>U6w3I2`zxjky+(iaV(QUmw!zw9r2M82IF`Oqkx%`>$(nlN@C3(zHPN@Wm1o) z;W17SmyjqFPTHDUiqDz&usq!4ukA2fCUtnsNGHDa{6g6MLB*rG4mL$(4OX-y86p?%jH9jM0*Kc7Qv1PGr%w?r8FxojWYcA^U({Uz)uRDzD>a7<4xa)Fv8R~}S_@gT=JkJ0D!}&r23Syj0v(`f z&C%w3SYrk$_k$euWGMBreVi;)v%{*mjrK)$!ip=u`vDZX4NA~G)z@&?+Ec1wc$sCz zlHDUiowAMrHktoI1%u2YIePiA?#Lb#qIJJ1GCEpReW2yser1Aq2y>ZhAH(QhomH$k z^~u3u1HHLKvw|m$D5#w2ulf(*7e^yYDHYQKsFvY*mY)CM<%$xbTEWIF1Vx|ZXWvsS zbTn8=cAhB}e2DnSt=JUxle_9Mkt6>O++B&XFE~?{cUeRSmBbkPt^>ig8`<5kU^^IZ z&T>TtWXm>v#)+e};@OC93}%T19z&Buky56*h6{}5#TO{HrHxIPhOnnyMt~oQnHLpw zMLp%ud3XiJ6<2MLMq4a4Io!o7iJdh;BC7p4bzu=nH-G4u{a;>m%b}Q`3?Z-Amp;lWs za@d^O5BH_+fXs@`6o-m%p5UmF)a$uv*W_WeteiWneB6Zr$v3cd5(n?rL!FrJ!1F;o@fqglwsgBJ=#~KQNoLHJaM8*GTwpSXlT=#CWr32epc=kEV{pv zj3LnZAf^17>e67Xi~stDgC^QxofDm!d@IG<$uMA06|Fss_nD~P3uBY*qB6m6Z*RB7 zz6KE)&g*MyzgsL^TwF*O7rgnWKeMY20o8JPZav5(cUK-C8}s%tEsJ6+am#RL%}_2Vlusx&jIy`#T|B>KXk>KY*fC&9_d!pd6iEhyxzb6(`p+4;~fHH)G$-9Y%+)qkr_6#fZOhQHc&Z zyof(?3KS0sw{2b>tyritLU+eEY=y%3l0NxE4V3oWX>d*sCQIU{$V>Du489)8U zmH&ti6fW)Ij%g22zeY+rlV8U1m?X&+K<<4(lChT-=48y)*T&7R;*P#(2ozJ=^*?VC z2Lwa%=`5edok zhX}7GhmnFQHL)~(9q!Hz=ZT63`sa~4lE6^)qwDG;234X|ek27}!A{g=rUCPf40&I~ z!`a!nznPxtn&KR7#`>dCIicigLQwIZ>FbcdeujzuwJFVg2QMjsaM=stf`690A1C zo+nsO&5etT!~BSE6%eE4o{R@7@x*uQb~6kt|8~Fc>5yhn&c7gvptzg1W+4WH zF=(2&9WwwH4MR;6``k7OltAOdTV`F3qnAD{6T)EHVN&Fm z%tLEER+eFVz+a@V&oD%|SyI<e#!Cr##O{wq3kD&XA@?H{c0si8GF z1toQrLOS9r-xZsy{OcY!t{i|US$>xbV1eM>ehN-E9ab5l*EI45w3$1KUV%iRgDW#W zM4={I{zIcm&B<85s2f-6k9O3~d3E55%%MVu>h@ooS9ZU0%8}f_=mw6(C$p1LBRNd{ zHcGSK%%sr@*HX$0XU+5zuKnG?YfoVQjB%BU;i*F@XlJ~}OxB<=h93miq(C%Gq^OCl ztt}RstKlR9MhZ=1Aw^r>e^9Jy$Mlh~eB zK0O3lBX*8su$Tj}M$<#~nb&r~n86PV>B{4PQwSuIJFCHCH_*Iit-Sy6H`$aK4B2UC zXWhCOeTSC^Lx1i86M)_~Zm2T`7VDQ6c1V-xH*$a+-`wCcfw$I8!UZB7ju{e=g{>u2hC8p}HH8 zxTCWv2&Aq-?Pfv}UDuDZ5wmk$(+}fh&^eBwZ{DM?y}iebpJ#S2w+BsF_YE!pW&ix7 zr?{o4Fd3_Fng})XXQ4ZpoDbzYjBl)JVd6#7uHbObF0S`QCfoK(FL)0&h_$xReygp{ zY;gZkjWeHU+@6OpNA8C;kD<>!rk`+1DR8a#J|O6E=}EIeDFEiohaQ2MZYn%lwpK^BBD^(M^ch;iav)-&BiP?Y)G;jH~tm0(YBoWWIjsIgWu2G=}0)H zpV|FTVczlBO3J*wA$9KF3AeN_tSBEVhGwbw}MlJ&5(NVn-Et zgGlum2bgYOFRN;37OH^ZoVBERB_jWu*DFKMdC)9P|0cPqt$md# z^S8?$oPwb9{;$1@6Y*#FMRkI!v<|&NF5vRDKi@2w{#z3RMiuVzu@*XEGhl_?elP}QvA(P7~qx0~@} z7k)KiE=1wLX-9<_`zORbkB0h#)^D0~>bNk6ep>mZ+#qbO$F7KuwzhU1NU}b7{h;1^ zr(ZoTQ=HFq5!e#;AhkL@LXOjEA6sCep{Rk=ys^o0TTLv4=qg@qO?Yy5DEC9FCw_g7 zrZIqT+fi3udH{o|T>-#c^n)D}J#`5GZCA{szLN>gJ8S!>F*8Y6w$*m$#FKOaqHU8J zH$Qi461$_9BV);32e3-fDaUV2@~ggyfyUf~|1Z+jWn%xA(^^V=!^1vF+)&Il@=RYh z4B2y{P>NM*>1d^*YC8vpjBdWS?X)+RLYG#z`_kve2x1U+k-2&7=eEw>^d!A2VSpz@0M(}3}jVQrsV9t?J0)mAQ zUo}vFkO0^-@v9@GXSBHmV6c?mIjP8_za!1J3Fc5V>QC72hMU`Qv&}^*3hF+@)-Qfc zs_Vw4SKc8j8XBp(m-;*7w_ETV1%Wm2lrK5_)TUS^fIVg8(l6;X8NMkJqN$gHyDZ{# zFAnrTq9i1k!>o7pBDwi@3S=_Z+z>F>>pfd?YS7jE|88^sSCgb;LIBf{Ksz{S%WZ59 z&!SVYb(82db1Y_Ut&`qGZEI_5t)zbY2_Ye27qY)Vw{Yli%q-o(AuwRz0O27@C?u2(IZR`Yp{X4jG*UwbLIZ{$d3;9v(U|vKNv>ehL`wE!y-)3M?iy7%n6hK3y6oiMzmh8ZX_Zl(T zyy}&emG+$|y8PN&F_7@>JbYaDM@KdFDU zC0-!wEJ*E<$uG*60QwF!p~J|Kpix1qz@)O(lwr_Eh}ZaQv%}B!93jH0)Jy&0+7$h4 zqMi;xJDkM3q?UI?WI!3hkTz+D%;=;Y!5qL6BytoQG<$h@<$wETF+5NUlI0IFo0_B= zmxW4IAogTGK+&=71BqC`=JvJ*IQxH70GQ85!KCT64^Mv92ub31f)XdZOEiVN zNbFBzOI5kd_D3uBvDJD6?aVqkDGj=zi(3Ner)tiv41GD5V6S7?z&m7-LFXDAq7~-j zuq^%2ubz}?uN+TPaDy1@_e|5)2rsUqZh}lI5AbqG;TLhjx7cb=MtSjPb|2J%*O&3y9N{_RyRu|63Glq615#dh?m2LG zo|u@}$H;LBLW$AI!8Clj3{h@IUp9yexJUvVc*#C`4cMxoevKbhBSxesH z(qI}CbitoagB0X3YeJ;@Z`b-Jf=u5SVV4=>%5H7Y5}1J7+MI|YP{Cuesi}!UhLJZw zd?X`(AEUr^F!w+ry-6e_X3i?cUEX#Mr*lp<20uV!^!D>|t1`IE9K;Cc; z)N&Bp4|-diaTlCIgTpqolJW_J@z$VBCM;Ny^_A~fhL+tBC%;3$t(}RLKz-eoJ`1_{ zh!aM6(OZ}`%m7vYp67t%F-q(^V8dqOO!52iD?4mABk`0M3%q#N<5bblKIe8!=a9Rp z%Yi|h-4O%sCv;@aS_LP=V0^mJ>q!96HV1+SwGIzu%b+r{YY#4&ea7&pa2B>R?hM(5 z-%>i`DWLY3m>imu339MYOzq@++wRlMj2rLT_iqA=#Y13CZvqnm+SauTY%{);U*85Q ziu#O!+aBpX$eSg`wU4GjOzGNK;MUlX%lW9w(k0DKRz>?jki5HpER8Fd_V2Z4yN^0P z0!cCwpuZcZ(+wHaw9;YaF>N>r#u^jBUJHBz=kA9G1q0zWRTQori(qoo9~}9QUOi!h z^O7a1&REc!$>D3Y1PQ6)Yf-L9U7a}vT?-vT#h3-VCWei9sM3rXd#Gbu$O1ohvKHKS z0cv>+SPhlXe&pGsrbZ#l~F|aH^?`DN{ zPaInyGfs}13)H52&Ye+0GB4coRA2y!ng=V(;pQTi>mdIW=S~LouhG(DsAoRQ&wIlWfYeQ_btqKwiJoQ=K*%aOGzF1@xs>2Et66)Vkk*rGy2 z6C<5&n`Qoe1n#Lv)S)m}2CX+Vm-I2yV*u=OwEmQFS>IR()P|Fl#}bZB$*si6PEAP2 zD?it2v(Jfbj-IEr5v~^!zKicSUCD`kHn`w^?&XrPZ zj1(21=jKGi!|UyaB(w+}Fvp(RGbM7m{^*s5syJb3R7BI-xHO>FmH;vB!*epL!Jek_=)NLp(Xs%z7s;)`K(tuNuUeH#rD9o+VGN9!zj=b z?FZ0s8+?Sp@=Cyd9rIva2s)ml7re5;B4L7(yrhUriX5^!%vC!9QqdI+a}ADcBZ#y5 zoWz4?8*iykz5PWHah;q=_GBc(tPI9KUZ|K7%i*q~4hIJZKM{82EprWzj4ZA7R8&-C zfuwZCz48R9f=ghUqr4%ifr!}MH#m@@=s+yG{br9x(W>2k0|*I#fR#eO-)W78Kg!5= zAmUiu3mpp|W4PVQhu14I_@qQ1h5Sk$>ke)7CrSFh0&gplMI)H@uJz7=W>(q}(tiH^ zSZ-p(Ko62d@o>KNdWmK^=f!NZlM|Sw@JH$f`-5ey9x{=g$j6G7m?ikLAYgpLl*W_9 zxHDeH28}d*8{dzIl!Aoqvy?}J(F-IZQy(QpNXwjEAfEVu8RRL@y0UMZD~)@P=;|*m z{~LiHkAm`<=DVDTkWoWDVw|^OiQ52Kzla7VfJ6(1g6ZZiQv}F=q|sI0JNMCNQV`M! zzB_nftmF0IkU5AVSi$b*`Mkox#5ghQT&kFd4gMU|F41qurL9TKiwSHyqkV^$i&~+} z=~M9vYwv)AH0MzIOt#>I-2@IIAyR=CfbY$P9-eycXg!Ac83>*E)EOx8 z8y-N34kFIw~h4re1m{d@%uQQzPVa0B^54PJInrPszN#BZ#) z9#n&2oJRsZbyzy^GeCna`{dj(trRF#k14Q!f2rqSWgTjQ@y72#GP-E=T}J6>Fag+q zMU?j6c)wu`v<~mu!M2Ouxl#~DNl6e$MPBDCzJ{y$Qjp(dYgVMCxMUhEPr-xI52bly zjR^>+Z2fcAFbtNs@LfAkPGHXLHSB6tv%Yu)&bj# z0!-+2DK*$+#!ApvB6;QqffeSNk5P(AtFLArMF~VRGv45y2DS< zc5)+9F?e|}MQoW7hm|i~JULVFk+$tmVQ}cgNsZuy0oTlF_`O~1&>-Rw7&mlZnXn4T zlOf8fIC*z#-hzmR!V)!nJmC|*6#AW0UX+GfkHXxp9}#G2LRM^-Tm&`HD|p5=op7Yf zWR+7_D$nS1!OK!w6oQJ(;V}vnv`X5b8=vdCkwKvLT;b4yf>zzr$KbwfJ6tgH2iIG9 zwiYlT;L{=s{rQEu?_p+`O|VxCJA(TrizOmM%!w$>wLqc#L zfrs-iHfMQm64uGbz7VWra%y&W{V`f>6c4T7t{nqfSQLyo6Mke6@XOZyo@zD^{-A}- zBlxsOetr>|Gav_^Tn9IWYv2dEFY=eL{f-Q1h|M!-lK_U8WkqPI2m zucp1mvLW;BW22MR%;+3q<^b1c$E5&e!!lXG|MT|4hYy0l&9aS+V*tc8kBp7w0=k{3 zi<0dP&ATN4c@r~AOL@)c#l|EBzRh-pGX2ZMd5>%1Tg+_DGFc|@r?e4Q(P zg_ow_L4DOF528&6>;t~v&qP_WZJ6%O*w9wFuZFkRbF*p820sVliw}k{ zql2kc#Yz6Ab%(7Yx$FXQOx_c#!wuFQmG(2LL1H5V#$R3!*3K7C%p|E!*3(*% z0hQ_|2O9@_$`!t*O*fdXARu-H9Y>4W6Teke2|qNfxDP#Awdtw_r6u3Q)Xl!L3TdP# z=cvE^l<(-U@vNtTJ>T~zLq}Ubonzuh$Ke`K$G-JETwfOC3y<3-sU%rrEDREF^gNBs z&uw0*L62#eeOxxZXc9bFNJh^+pvC0dcQy!G&Hv4R;4+IAkWU12cA){|1j7{`6|cyL zdohBs3abH-VFsj%u?JHb`yXLVRS=tuNjq-1S8QCnFQ1l#EMp7TEq>iRB|Hh;ZPcKx_?@LeL0|MBpeNEzcOIiA4HDrH>H*h*g?K&^* z^z5KeXb->nmwF~x?_&JFmXYVNEM5I%h@C)fVWp;L{ zu>kh3cRMz+R`wg|T`j@xE4=KQak8H#lS&ts_A^Rz-ec!gk8j$O&W&4449eLp5#T}0)^&5#JNah;s($ReCrM``>yH>rFDrKpf3)l>M8Y=Gju;Kbbl>spdyv4 zJF2+@Ch9nC?`)D|B)iK-YnXF+cI>HXzyS`Nb zvs`|gL=>4d2M!NJE}pB4On+&f0l*@^vGIJ>emy6<`mDXkbL~`p?E)Y3FhA4d-+;C8 zzu5yjCG30~F}mJ4H70O(uBE~Tc}T41m?S3MxGEWBAXd_^k<5g*fg1Phv~F9c8hCTO z@!q=UkyrKdz>-XmnbhdvEAOmEdS+G4ARJX}Gs`4eJ`}mW5W%al1FRD?{E|ERntZJ-~>8;@>)m2{Y;Zlb@cUoJqO^=y*^;7<+C>c)poIx|&UZ>59(S z@MnnJOpMUa#-~M9xbz5|9(O2M5eXAlNrO@EZL)#VswOdr1nfo}>AfvJ6szF!tlm*a zFPO3a%g=ZGMdP9LP*dHcXcMf%$gw@hbu3nDM z^(`zxFx=5d*7LXXRV*-$pcvqyX>KdDUAWLv8Ef*eVqcx~9!9g;F|6X;tWAqnz}G*a zX~NR%Uw##=40IsN-M=7d>V}9*spGF zk{3y&+W}IMBSmzTYeKw#g4j&2adr7ZrkK=-7@{;hpk7qO)`sbW6Guk^k+ZU zc^9deNTh`^-iy=)3JZ2me^qN-lN6xDKMU&<%Qq1E=#_aOgJp~gp_PoSR zQ$U_Bcc<1o<+)CNuYNTz=wP{T?F})LGSDBWj@(j@D{amYa+;b*WHUUeVu>je8aFe#%Ql`?{!8iJD`+~USGpYP)@x{Zm29AXZCZ?P z_Q(7nLfvY~MnflPWkskmr(clYGPz@RLc6^0iuR=TtlEJKR>r~b*(tq&*eD4p+^7?M zc6xw@yhZ}7kO+cy^dOZ4vuZNg%P0EWP2xz8tub`eJBcW<^EfBuT1mHhn0J$fMoH+c zJC98N!xg$AZni>6b&A=B0kdOc@uKGqYw9jfAi2|jxw;VVPywV70d8o}G4EvP%emMZ zt#3IL8rSskrg9KOV@Ksm$EqCV@O4240{O_U4-9^N&wZcQt= zEy1E_b)WgTa}u-sjOdLuN)KM8Az2tFWEUR62M5PWk!p6*J=OVp+^KJ9ex& zn-boqriol}L6-;}>sP4>vWtv-Al1Ve9uOez7z5)N&Bt_*`+^6r?68 zF_z&fmT|3R>UJ3=|1S zPDQy(VxH0I zPBl9{vDHV5cRZYzJbZs|NPj%imp#vIIxS;6O8r*al21jL2u&FW9(O2H2U}G-D9F{F zwdP<%(x+2NUolC0HeZzW0~B-T*cuxtF~QlgxL1FRwirGO5va_#W2p-R>)8fDeJ$oG zuNNou(njuUg`Ahp(=acrn^8~p-{5EDL<%@r7K*wNmg#Bt_x^@oE6q*wTmSO1yr1Ma z2k$r&n^Ri%fYIm7(f)fqw)B$K;Bzsu`k)Qunq+)4Z?UPXJ-bZ9Rsy=(`zV(d&w{F)Cu&S))i z>Z%*Y%NmFa@8Kn)O?lhoE2kQs%u$Wl7%`taJ9A1^F#m+tW&GVVSFtuFb&ogu%;UGk zn~UvS#{3hJN=ioPvmsEIXiafLYxcJ6qGyAPjB7rPEW;zi*}5JBn&r1`v+G5sV->AB zJ4#j0W05RL7!r)$y{Yi^db`$!?L>dh`CaXh*0!gn*4ukke}B{W zTFlrjOFRC)T$>>%`^U>0*|hI_?&d=`f6;HdEh*U5B8Pi^`&qTu{ksTE?PhUM$MCxOX*$Gv?GxqJUN7CicJ<~;xTj^ydx6?1|))~{^ zoN(Wue#mZF2PWZD7-6Z&K?DN38)2o0;O*DCzULdQC$N_y+SFbZ;Y6O^SXEWUwmdyN z{H5AfjefE?JDYtAF#MYI>FM9z!Ed1z3RCiAPy@~ZU}Wt`rb*R<{}-bq{2&_1A{FhX z7KZ0rJS~f42CmQn#&k16PdBODZh+W{gP{~Lk9{eMl?ArSEZPVlhuyI>>BL4K({I?X z`bSx5zby38Y%{vk;TL6dwD;TxgrP-=Hgh9t4&Pu@^_!(rrFU9A$2#kuc?X@&MxVe- zXQx2hyP;C*vxmZ5;L+Q|V`iq)3>zF#g=Gv{7EC>Sn3JoSKvS6aSu`v(MnJ=C&_ypL z1vm$0o@NN~I4@WtG};$~3k{Kmim%7?uTnTJr^VjR8OS&8eW|Wm>Z5M>YH4Grq*D5c z-qFP?_TR&_k1C)n(M41&g1wUnKLt~mZbxgr;|H|m(!}tUK@&yPN~HnjLxaw;Tqu1yvamCvo5r5@$+nsE+9)a%TBA7pTKclQK$wz zscxKFv8@!a!Q#->w(68}@avvIaa$)(9!r$##z=H#Yun-*EK!5KxQj+i;K3FYj&$&w zieeXup+N`W-JZ4Uf}j3`XQ1i4Pq((8TxyCD4mWJr5omC5q1{eUuUk>AdNQMb+M@rb z{)ebe55zd@o|00!OL5GhyL1zYyEzak>Pa_yu z^A-D{T{=Of_Nmj(sSGHPKERun;>YqyBs${xTM;Ivl_>=CJU22Q2H~%7DkM_eUZ%GmlV3X4Rt3r97xc8SSt?Bpur%vjo+Gk|$i-tfi-!ZlV`5Pl#x_WHxyii_)n~Xi( zuT-!98|k{0q%k?%Fm>3ZDu8(-$IX>#_BtP-9{c~bDYP0aBN>ya#zD?dxN0SG2XftcF0@E?)_5xu8p0$ z_68|?Q<_HiMIhB!19t56Ztzj_;yDytw{xVhYo_Jr2dJ~&$7m2+&meqI!s5MJ>BSsi zfm9&d30%L^ei&xw!EivTd!?F0rd5~(ogO1xq5M>P?WR9oJV;t931h(X_(?ta5eh$t zDYU*p-d-3|4uzK6SPp=-RwJ4R->4|~k=s-hJjhoxjBUz%B%QOZZC|&G zv`nnb_`8G6sefa5yHEU~`fllS_AV^9#GWb_4~?bT!&8C&gQ?#krcK-g@<(+CZ_C1t zT`?3hX(9SrRZH0IYP$puwgK+)a9&S&Pvf|n+1~N&A^XIWx3syT?S0%Ct%H13H=NKn z017lZl=Dte?v?tYPI;{%j{~~iu5s%sx0%H>*F6h2EBVp-n%f8%@7eYPCSr<;r*iK6 z2O0J6l;x)Qc?QZma;|vj<`mVrQIM<)@cgmt1<8YvLly{8pknG$HQzCcm(aIWuHzZ+ zv3dI~P1Xm+C1kZHFNH14{>HZ9dR79<760DLi}f$O{8k}}T9hP8Jdh$%7&&v7qD%Q9 zB}F-Mn~vhWvXjFz`p1uxs3|r`5R)Jbxazh*GY1lE-Af6vv8EJ(xCj{K9f~y)aXly` zA27q(essA6n;iCM3rD{9`z{^vg1SvSQ}qfv5*J%uOz{UQ%-r+BO3D}$;daJv_IlLW(&= zcRy8(Y*|*N&e}=opLB6)+)$-nOpb2Ntxx69b?cOUdAk%M2}uB zV(;(td!j75v#kI=Pz9MacOosVNDfgWfk)Bpe!QIRpe|5S|DiA&mE{fvS~o zWX{Pb9@`FP^~@2$lqs^F{GHh?Y08OT%_UFtI4e8_;a(I)+J12@!X&fhMkBLAtw-80 zx&U>vqbQ)-LM>X999sQ%N z!Dd~%?=qb648i<{D;X-gP$loIT~eb z#2mo%I`-p>lR-=ToyL2iwOgX@r(E)s!$(BlY%H)ZDroD?fb3M#iDD>z`F>(|#CMo6 zhl88^#<{(>wl@`}1X4-f#IwUXA@(`#FmJ9>N`-}KoZ3(fGx2q|T!&BOX`AH*Ao8B2 zYAw=YAZ$}wR;@vhDEyl{mcjMPY~f`F9>Z{(JLzxO>i@I?`@}ImF(36SSh-V*D%RtC zu)M0fsh;9ebJlXeH6DHpb{7#Wlk$C-*6lI_s~Q7dh_MP+fxrk3hp4ZMUyS;d56X3p zVCR>1$5q*%ysyRI{i%9kTdrTQvI*J!S(od9Buvc=xi{xT>*SuAT}dRt6PLb7A!}IF zNR}WSc~)xLpYh??qH6Q!WIaZGy+y(+W<)C%63Xs@{^FcHKQ!^V%l)VuVe54&PAg}kKHrMoaEkcT))IuuXQ|M%{(^eO zOgd1?(Wr>{<#pVbESzc+CE-jLX}k+s+O|nhjXQF#dCm2vim}QLGJB%`RE}Shq*;oR z_FW$o%iy7>X$^(f(zM8v6Xu=}jnlh>$P63W>ZGnA>C?MLY2b zw)*`xdH(iy5QFi?#7Wr@wi6_>3+09_j$q(>J)ZRY0Z_305x0@1(Gk6Lew0fAYLr=z zp=*0i2cAn{d?H@DrI8KxYub7A=zYTW=nTysGchx`gdRBMXZ^I%z5eE^5%#g|aBHFc zw1GY$?P5o+JUi`$7r^;_`jJ%v%#W8ZF!&SikLg`vMwb$rIg%Met`|WIaO5{y#zLrI z(N2)jxJ`I+gsM()CfCQ47uLYB3LN1w-RDbr79br_mZh{iSBLEFqUl!}_X<^3x7Nxu zr=OnsFKKFc=(bDE3u~4@=66Zpr?B+kZE>meiH7AIC_#}9^MFmXmU4;yI2^`$yf?Pn zQ~65!;YS))*0`HsKBPG<*3&SM*GC4op_fA543)xVZg$l?DyTh^cg>i_Ys<~dNqsah zB%#DneKjvu?FMW$?v=W}dVLIU5+2b`;OKj7q))XQI)~E#ILGcxezaz$q%7t3V5f%t zTa&3OL6+LB4{gE|v4azP{$)p)O&EDs%kvPDssme(S(Gc`gefDlu-I5qEhk8s`=#6v zQjZ^^oMfEX-zuzn!!Z4s)3?9|M&u8=cQ3BsY~v{>lBQ0S`1+|lWZg&?Md5KXC8TdVfekll@>AEcHa#z&mYN3&Wjsmysz8}XckMquwguB-Zw36xow9TwTZC_#G4yCA$Xp_qjQZny0gwwDDv zQHzVLs4HQfkywwN_fyi;8(5h_qlD13HkL~<=1tlaooTKc94%(GZ@#2tA>F9*y2AH& zJK4ORCCZR!wRCo%o}EH*jxYrR^2O+%-p>(;CW&e8T|qA4yp~1oOBHyd4Avpt)_##< zEgX!t*P1~Xfk>!p%g&q&X=mr$u*yl(<6X=v1}|E;fF=oE>+`U)qqM4BB?-DYq}LtX zSul#@OejEx-%k4MGbU^uFczJCz_mss>y1Y-4qEFm8y*$!0GqYqNrcVOz1X8y6@*`}hC!RO{ z2C>bE7YW79FXa**i%*j=8uK#>*ER143;Zg<&JwYv$%aKEziaSJ>s9A}_ zeK3?2^{#4`Np;_E7ji@Cr7}BRRf3(otK(wtGY|^+bH29r_8teB8sT-+5o^P>g%)mF z_Gclz8qF=%em=^EuJohMb{&c5L)*u?yBpbzLx0muY*%NEexVvB};3ZX~P4XASUV{n%LX?=ML-ny~hq4Ts&U=+cjhCjz954`qKJ+R0DQ zoP<*qKdR6Kac*@kOwEnJtt{+5ekAqKlF-=FlKEtCJ1?YFR_CLnN~d`g1w~g24lc=q zR)EJOfhWzz@YUWhI%Nq>j(8}}J3t>8X4+MfkNg2g)F$J-Y`bP)F5t4HG2U-)^unP1 zX7XIu#?ovCf20?Oj|xJ0GlV2kAI~4`rgW>;W7dCyfXBYcMLPH_-x%|KEXY5}8PCxA z8qe3QSyKI%XE9gD337=G7s^W_joIRwXszODctrC3o+(RAO*y$;VcfnO{HShwg!NJ_ zDM=)-?1N~M-=}TL)sUrzhb63CM|0f4^N*X4jPnC{O{G+A5XP+r z>=ZrJzfC-LLGf-F;9f|G$;82F!_9}d&6L8w~py$u;jsU8xh zmX{K{pVH`>`xG%x85JZ0B2p+Aw~+L7j77*^!NHZ@r3fbc79--wB|287O8$M@_5pj$ zQx-{_a4+I?e#>t}f*e{TwGx@?o;#sjcg1_iSB02F`pPi+ySpG{yJiTU;KoET-aT=2 zJzwEAX8qG+d*X0JlCFZT_n2(=etw;9EV-6>k4UcmERMcE)Dz?# zBz}2;s1%0>Y;#{D3;F~PiKoFJHpq)yBnG5lGzERzlA16y!yn3=hFKT*Hc!gak2Y4D z-M9w3#xC@@vuk$)!r9houtyHM1PCW|8?eZ>{@Y$?H1QYxYi+fE|K0IM^@e zGSnP<=b2Np)BH2vJeW>C{O9ikJ6KWE(*?R!uDFLp>x=d%jUutjMR-IE;~}n6PR3VW zwd@~OC7)DrsR2(S=OX3wlW+%yCvnDfcFCv(FW3&%vIfOK43g7m$%}ZE*;YhqXA@Kd zUVJpcaZeRin*Qv~65c$6ML}{BKNHWPWM4EAwm|_^*i&l?ephB-Yok@0B5X5(Tx)AhI@{PiJzMOH z3@MW7jJ!FIT^`0B?3Hgy*Ilp3Y`waec^oVIN`GuP{s!I7o*RGPezsSPn%;SZ4h{L` zVMNW;FUvrn3?9FyzKHuhu}v^nUMIzV@&tjDvij%8LlKme_pZ57v4*Q`Q_}iMP-1w4 zU(hw>;60e57l?!1X(zug&Ls<5cF{hyb`c@P{i(r}Ae!HR2TQYj%vAhg@@cSTnz5?G zzG@*RS#Rn~mzGAdSTm-2DS{6A8^V;FSEwUDEa*P-aY{k9 z+-c8Z@bgYz6xz0%C#|h{GfngSv#h@I4H~mWnVrcmr}4lQKXan6<{&}*_L~v4#MbSx zGx17Il!dkYUTL)22I)X{VL?Q8iKV5j{7}Q`UXB-;=RHt5%en8^ELob7Y0?hlJaoP{ zA9SY=cc!IEeve8CStoyA3S+YmSx(Dw@r%XK&GleD&N;U%4Kv)eiJ@3DP(UwJ>Vr59ban6##B7~7w-IbL?e2C- zg{+OdjTXOy>nP$#dlKV*+T$u|WuwK_AiTFrn8m#sF`@ppr1pMOk0k;7&wkE|hDIFN z9Y$#+&$m%$;k$4Q$J>P0EE>hrs4v}1=7JSAT*q0Rg{IvuSHanytaw)8GRt~9bbeiZLh5!whr zY+KQ(%rWFSTa?}HjwSYG|<`j57E4)aqYirp^*jyWRmUKz#GuQ4^(lZ~yp ztnRuPHAL9l88~Mw?Ocg7%U)>7Q}JG`?=QEpzWmHTNoK3swsUT|Dw~#XgMo=yQPOXm zgkM7X&imV8WKH1@mm>D=zSHHRrmgO5Yqla!=SA;)xMqm-a?CfD1=hc8;+*GOpqb&6 zQ0^USeApnx2)dwL;ApC^i{dS(MsVut`gDPK7dXQnBjvysWq(Rtg<9;oT{n7?a(v$9 zvMx(v$d}C2<^=7hFOS;l=5k+mtuhS-occI22%W=fM4>iZg&8C~#og+39M<^iCGNA(p<^ zA_5~R7H>dqmsgfzw|0@Z!^8Xn5wP(duDaW{s!>H1!sqbwd=s^}j z#Q2VZTJ~N8zaS9h5ZRS`#nqU6HM9PkSMc7mU6NH49xFq0*o*7?Hm*zu#@|m@co2h| zsg3laX-j6vmJr*{_YE*v#H-em;CK8{dfTKH8*R+56xv$vy)Chu9Ee)z22eqNx3(NvQ;MYeYlm2PR1j?XvPzzuTWv=H$60HizaTbzv1TvF;^i4kn^_ zQ#NQyE^sPBzoHxDRl4f9jGHJWko?nkBL@yuG?`bG>E)MkPdO2CSrZ`a%Z#<1Ys zC(lFUdeV4u|u6PSS*|5%pJL_cpnVs9iK4qgd}?$ zPVO#YooNqB+|f~v-;Aci@zT?l?Q6SJfm_BKd5c$bTazvKbaJoId7e7ke>l8k-Rm!- z_g{N>010Jl<5LciSBTpbk&WB6OLWH?swpJCtqyY^q;7S*T7ZhhByB)Z@xpHj$j1)3 zi(KUkTM5Muf;5OEeD0oz=NB1OTRsjKY9R<)qIhqPSo zy-3&wWB6ijY2nN3Oec$RLfwDjebKi!PZH({me))OmRFt2GUxUlk=!@+c%Dx6?9$Nh zaYdFd@sBzhzpDC)Y)iR(Q@eGC!i$LtEj7M<4*3YZj-tK!u%PIehwyfYOcEsl`^KQ; z^9LyN{1lN7{^RHc=%VT{KJPmMQ9JA!)*{B!FSSJXxt?Qhm zUw{@$%u#+m6m|R2gc11yh~0y|UFS2_^(hZ?f!1ohokYc~_Q})u?zFWdz_Fu(>niI5?ZBG&`v(o(?WK#SV%lHupHaBp7#tZC@R>Ta%V4hzLn9= zYAzG9Fpyaw5aEeDKYh1%gzWrm)wkQaoKshWjp;jB^AvWnaeYw!wB}iPr|u{I%8QWn zIktwC1;@da=*4$Fqs}%Rs+kyTzQM(JHda6r+2ChF1=_rwl;2cXoZPBNU*PkICpS_` z=4)36UNM28aS%myEJbt<7hQE=+6n(u6A<#lGpS}Lxvzq8v+XLA+i@M^XIs6}2D;LzhbNMLr5u(5HFrCqZ4Jzv!2t-^m`+m7|D=+gd7W!BN{?VZ%H z$!zfy>=z~p{pG-2huGV>32tSA@kXHQn9ak4r%^%&BK+ zJuFqeq;u9(51|@n8ejgjA?q8VX?8`no#JKPY{klZ81(eiId&@hVy9^FVG*d-x?^^~ zsq9d&*)6Pl;dkIpBE&!A3-p*+)r*F2t_~KoCm(>V=I~axurs`ny%T6#1`DMOyMe%l z`>#!V2gk)u$?J6b%6685_+w zX}rb&h^F=De-zLD*gphlLLG-r?jzpq4$kgBp7fXMhn;gDPMWPHdmjdP$3$~5Jup5G zt1p?Wwa+m9jbS^6pgKp!u!y-jn5?X)RkbB%z+&+S&GL3)#C-0waV)?p>DE+d{^e6x z-mg)#PJS!@L!)0!IPhEuSMCLJk$oe-^=1F-^-}hY=jdmxL4&WUzdmG@Ha;>MQE-wv zMlN#c84L2SMSL0H#e@unei`I73?s!r2-#)*vw8j8Z?}+(!Vw8tH@KkSMsst`x7F%)3xC|V({(s>4dYyxzh`>y+Bxrcwup+m5t9_Yts4~+RmoMKnyn=C zy{}gkLP46Yi0^73eV20KX;*vs+_JlhBKlKjp%xQG0Z8o2@my?eySkjd{D&axa^%)S z-wccL62iDQ{v#kn&tzmU1H@-y{SCP18=o-M?H77KWpmz@<=MrSsSQf#9Jc!<4W-?h2fb)|Fgly!7blmWlsyl`{ch*Rxb z-G{}ARpuru!!-}De~|JEj$D6Rd^?J}GbVnq&&j}h$|?T$ZpJ8 zXZIi58`tuB5H*w;%uJW&zLWhh)bTn02|pFHl>wx#`f~a!H<7o3j5T1%bBE*u2FQeP zVY0cyJLeI_jFQoGtkpAtOeu#{ppfV0>(lih`+KY(#0oufbjYMsF<7vZ+3fGu0k|hd znrUv9C>Omeo`C*YL?eG})ZF{&?5q{}wlj{KuQ||F@~ZX~Vh($;Q~2b;WgHQ>5wC-L#0qh0q?WZS~-pw@l)1I@1cnH<_)Im>g%tJ z#pK-v2snGgfj*2eYs6$!>rqh_c( zct!GFoW%>fnVF>9omF?>^^CwVSr3aZMGW{72Mbu?aNO;qU?<|_5;o6eSt-x(5Q|w! zF|Kr>sNse=4Pw=Hs{%W=W6EONg2m zO=D6EhHu}uLR=2`{)BwY zv$l{q7}JcV(z)X)PrLa6H+!(GEu6*oT2=<$!fb?Af<`k~k7^hw7|A3Sr;)zTMlLmL zcq3m?kTYc;N4O^3LR%ryH-BJ)_voBU(%G6d&VNdo(1r|WA(3RW4>(x|vvZHZZaq3* z=Ho!DSo@C~ggfaYHhsHQY@wlbaGo0p(#+A^8B^HC+5QR67_U`tTfPnHkT9pPWw9Zw zo7QkOyXRpNTvmH5e8Qu!&`D2gxB5JaY9aZ{bI*m#InL+5c`B(bwQL?{m|OpjOduhY z^1)DG^AbNq5@9^}zRkP}$>Hc& z-m_Ns@OuUsZ9hwboyc(2uG}Y)3E#h_?8veO@&@zArIt^;omEEbHkTT@G3$+CLuFa# zn?v)bv+5_JCi!wD-*otKbyebB|EG0ShT^?D{C$x1MjNwWXZZD{y_k% zR06d`%WMuWZR;u-kNoT@+ojoW2h@F%jZ_hsHBv_AOD%ZMja_G59P?-2YeXyaQK5@b z;h}S~u~i`N;-u(EByNZwj_tBJPgW*Kxs6U<(%w5i(%0^LA3-N@FU+Bf2#`vuR`_#y zpfYZU-)G)%ODs`&2efMCgCdx`sQWhpofqfg>?7>X-1%3v4nYC+ z2egX%=%ljMu5+Gv|MoAL*$6HJXRUGKb|3Pg-mZb-g)u6 z!X|rThjR6C)90D@_h8+Hb)J#k^IagSM~5r|mLz`tfpY30Bi&T2ZG-z8*xc+j`Ekl1 zbk6N}y*(tzy!Gaak+S~MlcTcrDQ_O9{Dy-v0wS^6O*|9Dfu$;B(%qRcaW-_%P>|2J z{3g4@G7_f9tFk1bdhYAxe7mr);mWio*F!6c_nKjnT-)I?>rfgQ+LP#Vq<3Y(xx#2) zl{;d_aM#vxOlW=i=G1sHEww~LE{UmR={cTA`d{{3l^NjbefNY#;rXl|{l;tb@1EBd zZLlAPPgTZMg$5Wc8vhIo!DDE(`y%Pm%a0nfPAMbX{M8S_xsPx|C%;(L{7skRdg8HC zRd{NUb}Oq(p6EeV1#fqR7dJx>b(py=Oukf#RF9B+AZVMPV@dB8;`Ri)xWmjGIqur( zc>KmB*RilE!}asQ#Po(4iXVo%%+#H|@RN~lCK&@cDADQ;ISn#fFwVG{A&yfjkXN}g zc1JqZ$%#)e6n==dq6djqp--zl0;jFZ7~0Y0jU{og=%AhbrBaSUmb8Y}C4m!{_t~KV zsJPI2(NwC78@@2ac+2i4-u6CCkBCfrx#My*XuDb>dq&xlGL8R@;K}J_koo-biZABD z5rX+zWw4RY;iLgwLJ)K@1-Taqy`%x9Y9M9?Dfmdn?}LQH%J1jJHwP#f<1j>wAbYMT z72CyMDUk8_Bd_ZFG4^_}5xK1N(CCUYvXcF5T8iA@v?Qdhahup1!O$2wl-$PfS3v>< zi-N?bEZ%OXdUsQ>!E;ObN>CgjfFU*B2fheOGmM~aCLnQ`eKR*C9|*YSF9;=B86-$# z=AE%Xxm@InwO#Jrx90k)D4?t`d#&5xhTxM49u7VtPjKm>pGq=5Aj1j==j*@A;hgH_ zzHCN_iI^o10RA`$M=MlRRJU;wP(ObB;9_S7>Bcy`r333DrHr_ZLBZ5Yot>RGa6tFR z0|pg1_Wu!+@V~)2{_g@0|3_}_bi?N-#89-fvf4?{GCkcWxkX5r_3qs}2q{MgT8>>4 zj8Hw|R6_>`2TOq2n=w4~79^iBR99E`5=TL!kW?PZy;XLiOr6MfENN+JDRZ5OR!&Gr2y_h2qt75!vKP2=iwn!kxz$f4%qf1%xTv2? zD2RAIkFaDbiU;z+8_Exl)Y3@pi^ zAW*iRSQVO_|4JkMp%ViDzuw;7XIc<904J13Jp=ssFH=_OAx3cqg0%Ez%5w7o!u@op zp@yOlQ()s(3!4|R`fxU6r;(8eeroRqAWn~*&V!*xQ^~x>$xX@0dD59AByc^5fPlcF zD}gTua7#eLG%hRn;3-|5n3%8tQv*uRbaHge=#1xC+yqCb9kXw6ijeOf0BfKf zV!mN>brqVm_SJoUcT@1`xk8m=4gjpGVXDmH3j_)E;RKnpDKcp*Tia+lMd-{&7;t7V z7(^<%iS7XIgOk9gGk*#3J53^_w}nDK0gQxRK#S?!n5emUg^i0a5e8PM9EZhxTcqnf zh-Csr{4D%X1(#1dW`UN76t}00m^}ea^$`4zcOnPMh&8V`1ySPjpP~f>1TON?rHnz> zfR}{6(%X&k*wL{uhf)*yXwWpgDo|4FgE$sCz|p*DDsj3%aI1@MW;Y=qBwQ%^;#&O0 zXe8JKe{)&y4?(BlDNomW#MpnfPJ&2u-8*ffV?zAn1Sa#3Wes6QU4#5h_$V465_s5 ziJY?k@Z@9?IQY-iEO8vpPEG(BMlYW5YoTL%RM~a7xA(JHb9gvmC-gEgN=`Qn@IxMT z0C&4SLsL3L)SJow<12CG{+p`10 zEHsR}tepTaNe0J04T!{7V3`I}bh;x83&e` z^2>REp7Q4-v~E7~Wqt*WuC{uk#;-u=bB#1VYccPb6Qbt=2XBESi>!QeTG)ME#F4xj zM~W*ihiLE7I7vMCC1GoXW>c1$Zs<%5sqXGUAPTl!G8OBwIi}1~)eabRv)TB*2~#5<&}8H#jow zctKDA;HffzLWXW*b{jg^96-sc=)}R_yefb_f%+WDte(YO_?>=lcQ-?d*7bqSWGx?f z-G9m0f`Wo&sA&C73f7MEh!?65ZMgx=wZ>&RNCacf4T0-9ot=V#gIXL~`!8fHesxWa zL+Skf{{Fb1EB1KLmCF=*-UJks^SV1pXc^>11mr=DIxS~F29oIG0rCNov7G*pjE&{7 z)8hwdO(Gt{$p&xle}pfT=SxLR-DeW58`TAn1mg#WfEDNlc!j&~K}$4R088432~|)4 zF{nYrDv*_@by<*XPPl=9g3iE*bo=|8apwTzcNA-u^jjZ#{6CEZD)(f7AqQ!HgtUS& z;F@VUK&n7}20+ef0OZ8!;yQ)|)CJD_-4iRY40$JZyiX)+SNoURbD?7C`;+tx^vg|( z8HO&wK#*nxBvFIFjk5A7AMyc2+wvo0a-*jzU_KG@pBx`UT`za#;!+>V0Z)=%`Boeb zCI;gXdO@=Pq-4z)LX&AZE-vnoY9P?fo~n3+ne<<$2UnA3vf|1a0{O}(Vr^~RalW~> z)}hbHRLp$*+SPY*`4nR9N?4K@7MeNOr%F4wesMu8y32nHU59K?9ZXtV}oz;-Qv}c)P7)2*K7A))s~+NYFjHj7tx|R-^U^D=^28l)9B zK@t;8YS79Z|0AHr|Nq*4Zx5{iMCvXKCU^=O+2^;Y58jq9m}31sJs*Kax&u&TqsW^i z3|wnKiwEZ05oTFX|#Y8@@TV0)y`Xgvz73T=VXU~)v z&_~cpx(q`Aux22nkeeaJE_Ep0r@2H7!wDS5t=|t?AORruk9fT)HJ}eBw6*KAzr(cs_Mv*aaK^`4(@b)TdT!!H|$Y*MFcj(7MaX6wNBq$_Q!H@q!1_cYK zY3=LZp8)|?M?M@3Twdd)NV*gmwF0dR*1q7Ii;5r+7Z+R!>^AXhDfoap-*=Tl=c6ga zt!L5t3ZYc`LHg#!8IVl#O-f3Ffb4>44!|BCt#fx?`Dcy$4@>K*$65)xH=IV`O$_CQ zb^8BE5E*EBhv-Gn&E(P`GqJp^YzdTt0J0|-l0BN$juu*VZcesMr+>aEX04A^GJjJm9qD>>eI1*z4|{QZz+%cQZ*%?>$lv3g3kXY4O#w^Gz?vVy)Xl8 z*?$>_plYL%zCPUIax?hiFj}BPK$>9;-X&m&1)KgzOiZk5D=H~LzVZ*mK(X{0^#>U2l!fPul7mnZrNDB$o;?fP@ zViyHwez*=aG#g_JSaC&y@NR_^VP01Oi^f~XWy@e?ZSCTbM=MOXalQsw{yfwsi2x;C zpv9LCJc6COzr3R25zb>$Pne{4014GwXJ@Ml&{OO!{TFUlkysResFYtI_}+3tPE0SM zUh{b-X$a=^|La;W|0Ax?OXAQsO+icmpAu+-<;_h+$h-r_m^WPna4UnRZ|qkZ0wWk_ zS_A5+e9h8VP%V<%b9Mp5V3HYFqE>-y!?Ax#a&K^GXb}y$KV6ot0X4a?KZQsfC)~=w z;NEpWfuo$63BAktN=!^F?jQAjx&t_d4Eoc~e}Z0u(Mvuwvax|&YZw2Y_9N?z26ui1 zI`@D7k35}2Dmf4Xrw3o!j8)we3>1_o5XtShX{H^}|DJ=H!}w6N870j15g1~PvA{12 zz2X0rO@h<&v6{`{+%|aloc>Y@3yZJZNGalR-mB+iAMg8q{6%M()_QkuG1$GCL{5ie zgPfG~T#Pg7XoY(OrD}#Q%9=9GrU@ z=6PlXTIJuCmdr!K!pdV}D9_<>5q;T;+7c4}kx@~{4Xd&suBSjg{`usGw6wHaN;S;P z%scaIBc*C|zKhYLNnV4CJF;lvaoJ-{ee}gsMdSyxeJDM)6{MxNwun1bhzmjKmBHYU z5YHQCA5q2ZtgKxW{(&MQHj3+_>jfy(ulLQIW?dS*T!TBZxUL=^wE=(i>nhMQLJu^QrOWR&KOOUPuoD*@Nh-y*q7w({hU%CEF(F+-ylFJ5TmHj!mLc=+(> zc)&|WMkaTZI8~-S%Pe$MPibVlP*=c?4u4{W%D`#@pRk{0k!+G}Eml zeK%X~g&o=ZX%$7rH+>zU?}|eutI-g-B3r}c?7LMg%s3$O8sC@bDlNW)CDorR6H}_4 zC?%9I<1dk&)aSPmxw#VqkL*Lr7S)N3R))*;lOJmhd!Tfz}t}=GB^-q4zN_UZTj>~`p7bUIF2>RlE!XD=1rnRoW!pUg6 zo7<{Yxbi|lUX18!I6l%`3`KC&R*UPAk-Wq#)Xu&q7lYNOm3SXt+;AKmek_=8ag`pI zeuX|JFr$Jz@YT?wIzp~2EJ0x84(o9;K~jQgLHf#m%)r?dPDqCYokQ6qd@YUm^xo1X ztuf>n^o(+9mU0W{85ghe@bGVw44(_kRz#TjW6gelk<^Sn6Dm-p5p6c`ri8>ixLzY> zloY2)gxt681HreiPtsYK9pVeLYx}=>pSDPLCRChe}S`|i@r+_8z7nf_K?+**qF`I*|QS4Mn%e5%H{gM&wl_lYg* zM!oEl&e}C>-OeQ_bYIi(I~IRoR2X0j{I(d^6D^b^{SS&hV3sQU-J`}_G>`>|nO zOKWAxpO+SDY6#1!n*FiZJ}39*P)p(TDA7L?2@MG;1%#76$uKI_y|_!I@Uz38!0TfJ zH12ZnN~bLMY7QpdZH9{t_cv-bN5OtX({mCjP&;}ZVD0utls0QN>u=Vp8_t0;s=dv{hEf{xOnHI0h+ccDDII6aF4w|+Ztur>i*zP1Ng1Erlux^ zPNgU&-nRO8x2CA5FF6Ip7|_|*0nN8~4jrNGkowVTZMXz*)&;5A4SZUfn_cWtINN}c zQweT57(tU72IK3cb(zL>KUuG=>%4m4*Ok4YT8@sAEM>Op_}L#jXT3=@Wg*@AM4>Li%H6t1e|H3AvwTe|&j@Dbj}%?KN>&<@$;xUYGCL zS}kf&vk@zTIDl{GBh^QZBZN$^UcGu&GxIax#EXfm=Hu6|WbOcEd#c0C)t`pdD>ir+ zU=tyHi~>}i_Bejk3MwWFCS1(0x9> z5nyAfIuS5@8a14%_Y}HEF+my>6g27OJEV2nNqYnSXy95{k%y$}hk%+v>-{08ZuG;4 z8m|@!@6c%9MMOtw{wVpP_F4aRrhiL?v1%4@q9zMrwA6+B2<@h1A41X?=iaQu?gOS= zUJnqfD){S_d6vD>k6G>^9pfQ=@4D+$M8bzD>eraKJiv?AhU8At6|Vf0PGzbBB|0$Y z1zVDfGvNXo#i60Mh;GAA)=J~@yKHN>C5i6pdt;rns~vL!!tnSSCfVNZwr)H2KVBJ1cj8421Fd6 zgs%xLkq5gEo-J+CahQEp=b#<0ShSl+x|ryJ4@hY4Fwwn zw9^>a)?0g_twLm|I>BSydTVpzY|?#m1QhhkZFTN10XiodFn$<{YHL9$zx6MQ!X-Zg zh_uC&E0Kv-{&VtSqv;<=%`Ff&@;Qi3G6{01c9c?6E-fo~)@ZNfZ@*m=DO}g9EdRUr zD*2sjj~~foXVup0qp(4Tp zH7)fn);HOzsBZ8XCnt6fEJVjK*6mR&JNJ}-gjN^l#@ROc-Jq4UJQc^Dfk?=My(e-t zfbMS%D)bCyE7B!PzXnL`eNddkU43YW0LI$`itL$)1rYB2en6&orhTBYIY>!i2Ih3- z?=OreZ@KRJS9cX^S9ZzB$`(BLeJO&7)g9&3NRZgD$v<=5A%wyk7LW#}o8M&JYAed*dSxjVvEf7s+rw=%jEr{}phP%lJNTEPj-i>XSGm*{SwlD#0HEtxAh=>%X2-{=GTVNF&BEJ zPhkJ9O!8Vcj$CT<|MqqL0)iN~WFN)>yK}!sjL5b4 z0Z{CAgYF}KXNxG{hCVXJxOWjPlobFVz3bx)`&U2O0oL5ATW}}zx^OB=>u;2{?RGce z-Dl13nVgb6K`o*+P_rd>H2NlHzyG0n$YA#LOIpA1zyC*hh$TW`cTd9OjcPM%N`!<~lC*B5`(baWv;};4EyqKa-UfK2vy7Q_N zC?$n4C2Ey;OgXV`^2uhyRFc=RMY6THjbiF3=)tvhl~qiP@2Hfn`qjYPi~;e7mhJd^ zpg`K9t{-`?1zovk`Ta2M91(UhPrZq>&8kIOyJ-Bsj&zT|djV4Msn&J!2>gn2xaB$i zUKx$&+5VW_!Mb?|Mya2W9B`7{_lCG;y5sHYmlx$$RSZzS)g6igSscTP}n9E*)|cqA8O7i~`C?p!K1R@!xgD053ZX?|_oPWQ`+ zhINzvoW#lwK8b_Zh@HjFEX{7i%LLE_^^xB7=2Xa^V@f;Hg#Lahp&(u;YT*0702T void + functionName?: string + templates?: Template[] + initialPrompt?: string +} + type DashboardHistoryType = { sql?: string editor?: string @@ -47,6 +65,19 @@ const INITIAL_AI_ASSISTANT: AiAssistantPanelType = { tables: [], } +const INITIAL_EDITOR_PANEL: EditorPanelType = { + open: false, + initialValue: '', + label: '', + saveLabel: '', + initialPrompt: '', + templates: SQL_TEMPLATES.filter((template) => template.type === 'template').map((template) => ({ + name: template.title, + description: template.description, + content: template.sql, + })), +} + const EMPTY_DASHBOARD_HISTORY: DashboardHistoryType = { sql: undefined, editor: undefined, @@ -56,6 +87,7 @@ const getInitialState = () => { if (typeof window === 'undefined') { return { aiAssistantPanel: INITIAL_AI_ASSISTANT, + editorPanel: INITIAL_EDITOR_PANEL, dashboardHistory: EMPTY_DASHBOARD_HISTORY, activeDocsSection: ['introduction'], docsLanguage: 'js', @@ -74,11 +106,13 @@ const getInitialState = () => { } const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE) + const storedEditor = localStorage.getItem(LOCAL_STORAGE_KEYS.EDITOR_PANEL_STATE) const urlParams = new URLSearchParams(window.location.search) const aiAssistantPanelOpenParam = urlParams.get('aiAssistantPanelOpen') let parsedAiAssistant = INITIAL_AI_ASSISTANT + let parsedEditorPanel = INITIAL_EDITOR_PANEL try { if (stored) { @@ -89,6 +123,9 @@ const getInitialState = () => { return value }) } + if (storedEditor) { + parsedEditorPanel = JSON.parse(storedEditor) + } } catch { // Ignore parsing errors } @@ -101,6 +138,7 @@ const getInitialState = () => { ? aiAssistantPanelOpenParam === 'true' : parsedAiAssistant.open, }, + editorPanel: parsedEditorPanel, dashboardHistory: EMPTY_DASHBOARD_HISTORY, activeDocsSection: ['introduction'], docsLanguage: 'js', @@ -206,8 +244,12 @@ export const appState = proxy({ }, setAiAssistantPanel: (value: Partial) => { - const hasEntityChanged = value.entity?.id !== appState.aiAssistantPanel.entity?.id + // Close Editor panel if AI Assistant panel is being opened + if (value.open && appState.editorPanel.open) { + appState.editorPanel.open = false + } + const hasEntityChanged = value.entity?.id !== appState.aiAssistantPanel.entity?.id appState.aiAssistantPanel = { ...appState.aiAssistantPanel, content: hasEntityChanged ? '' : appState.aiAssistantPanel.content, @@ -227,21 +269,49 @@ export const appState = proxy({ appState.ongoingQueriesPanelOpen = value }, + setEditorPanel: (value: Partial) => { + // Close AI Assistant panel if editor panel is being opened + if (value.open && appState.aiAssistantPanel.open) { + appState.aiAssistantPanel.open = false + } + + // Reset templates to initial if initialValue is empty + if (value.initialValue === '') { + value.templates = INITIAL_EDITOR_PANEL.templates + } + + if (!value.open) { + value.initialPrompt = INITIAL_EDITOR_PANEL.initialPrompt + } + + appState.editorPanel = { + ...appState.editorPanel, + ...value, + } + }, + mobileMenuOpen: false, setMobileMenuOpen: (value: boolean) => { appState.mobileMenuOpen = value }, }) -// Set up localStorage subscription +// Set up localStorage subscriptions if (typeof window !== 'undefined') { subscribe(appState, () => { - const state = { + // Save AI assistant state with limited message history + const aiAssistantState = { ...appState.aiAssistantPanel, // limit to 20 messages so as to not overflow the context window messages: appState.aiAssistantPanel.messages?.slice(-20), } - localStorage.setItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE, JSON.stringify(state)) + localStorage.setItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE, JSON.stringify(aiAssistantState)) + + // Save editor panel state + localStorage.setItem( + LOCAL_STORAGE_KEYS.EDITOR_PANEL_STATE, + JSON.stringify(appState.editorPanel) + ) }) } diff --git a/packages/ui/src/components/ExpandingTextArea/index.tsx b/packages/ui/src/components/ExpandingTextArea/index.tsx index 8184f243e2e..8236886d4db 100644 --- a/packages/ui/src/components/ExpandingTextArea/index.tsx +++ b/packages/ui/src/components/ExpandingTextArea/index.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import React, { forwardRef, useImperativeHandle, useRef } from 'react' import { cn } from '../../lib/utils' import { TextArea } from '../shadcn/ui/text-area' @@ -14,31 +14,34 @@ export interface ExpandingTextAreaProps extends React.TextareaHTMLAttributes( ({ className, value, ...props }, ref) => { - const textAreaRef = useRef(null) + const internalRef = useRef(null) - // Expose the ref to the parent component - useImperativeHandle(ref, () => textAreaRef.current!) - /** - * This effect is used to resize the textarea based on the content - */ - useEffect(() => { - if (textAreaRef) { - if (textAreaRef.current && !value) { - textAreaRef.current.style.height = '40px' - } else if (textAreaRef && textAreaRef.current) { - textAreaRef.current.style.height = 'auto' - const newHeight = textAreaRef.current.scrollHeight + 'px' - textAreaRef.current.style.height = newHeight - } + useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement, []) + + const updateTextAreaHeight = (element: HTMLTextAreaElement | null) => { + if (!element) return + + // Update the height + if (!value) { + element.style.height = 'auto' + element.style.minHeight = '36px' + } else { + element.style.height = 'auto' + element.style.height = element.scrollHeight + 'px' } - }, [value, textAreaRef]) + } return (