mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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 <joshenlimek@gmail.com>
This commit is contained in:
1 parent
710131456f
commit
28fe5811de
32 files changed
+1471
-252
No files matched your search
@@ -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: <InlineEditorPreview />,
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
const InlineEditorPreview = () => {
|
||||
return (
|
||||
<div>
|
||||
<Image
|
||||
src={`${BASE_PATH}/img/previews/inline-editor-preview.png`}
|
||||
width={1296}
|
||||
height={900}
|
||||
alt="api-docs-side-panel-preview"
|
||||
className="rounded border mb-4"
|
||||
/>
|
||||
<p className="text-sm text-foreground-light">
|
||||
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.
|
||||
</p>
|
||||
<Admonition type="note" className="my-4">
|
||||
With the inline editor enabled, editing policies, triggers and database functions will all
|
||||
be done using the editor.
|
||||
</Admonition>
|
||||
<p className="text-sm text-foreground-light">
|
||||
You can access the inline editor by clicking the code editor icon in the top right corner of
|
||||
your dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlineEditorPreview
|
||||
@@ -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<any>()
|
||||
const monaco = useMonaco()
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const placeholderId = `monaco-placeholder-${id}`
|
||||
const options: editor.IStandaloneEditorConstructionOptions = {
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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={{
|
||||
|
||||
@@ -139,7 +139,13 @@ const TriggerList = ({
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-52">
|
||||
<DropdownMenuItem className="space-x-2" onClick={() => editTrigger(x)}>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => {
|
||||
const sql = generateTriggerCreateSQL(x)
|
||||
editTrigger(x)
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<p>Edit trigger</p>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -18,7 +18,11 @@ export const generateTriggerCreateSQL = (trigger: PostgresTrigger) => {
|
||||
const events = trigger.events.join(' OR ')
|
||||
const args = trigger.function_args.length > 0 ? `(${trigger.function_args.join(', ')})` : '()'
|
||||
|
||||
// Note: CREATE OR REPLACE is not supported for triggers
|
||||
// We need to drop the existing trigger first if we want to replace it
|
||||
let sql = `
|
||||
DROP TRIGGER IF EXISTS "${trigger.name}" ON "${trigger.schema}"."${trigger.table}";
|
||||
|
||||
CREATE TRIGGER "${trigger.name}"
|
||||
${trigger.activation} ${events}
|
||||
ON "${trigger.schema}"."${trigger.table}"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { detectOS } from 'lib/helpers'
|
||||
import { ArrowDownLeft, Loader2, Wand } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { Input, Button } from 'ui'
|
||||
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { Button, ExpandingTextArea } from 'ui'
|
||||
|
||||
interface AskAIWidgetProps {
|
||||
value: string
|
||||
@@ -9,6 +8,7 @@ interface AskAIWidgetProps {
|
||||
onSubmit: (prompt: string) => void
|
||||
onAccept?: () => void
|
||||
onReject?: () => void
|
||||
onCancel?: () => void
|
||||
isDiffVisible: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
@@ -19,42 +19,54 @@ export const AskAIWidget = ({
|
||||
onSubmit,
|
||||
onAccept,
|
||||
onReject,
|
||||
onCancel,
|
||||
isDiffVisible,
|
||||
isLoading = false,
|
||||
}: AskAIWidgetProps) => {
|
||||
const os = detectOS()
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Auto focus input
|
||||
const inputRef = useCallback((input: HTMLInputElement | null) => {
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
input?.focus()
|
||||
}, 0)
|
||||
textAreaRef.current?.focus()
|
||||
textAreaRef.current?.setSelectionRange(value.length, value.length)
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (value.trim() && !isLoading) {
|
||||
onSubmit(value)
|
||||
}
|
||||
}
|
||||
}, [value, isLoading, onSubmit])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md p-0 bg-popover border border-foreground/20 focus-within:border-foreground/30 shadow-xl text-sm max-w-xl">
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
size="xlarge"
|
||||
inputClassName="bg-transparent border-none shadow-none gap-4 text-xs focus-visible:outline-none focus-visible:ring-0 py-2 pl-3"
|
||||
placeholder={isDiffVisible ? 'Make an edit...' : 'Edit SQL via the Assistant...'}
|
||||
autoFocus
|
||||
<ExpandingTextArea
|
||||
ref={textAreaRef}
|
||||
className="bg-transparent border-0 outline-0 ring-0 ring-offset-0 focus:outline-0 focus:ring-0 focus:ring-offset-0 focus-visible:outline-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-within:outline-0 focus-within:ring-0 focus-within:ring-offset-0 shadow-none rounded-none gap-4 text-xs md:text-xs py-2 pl-3 !leading-[20px]"
|
||||
placeholder={isDiffVisible ? 'Make an edit...' : 'Edit via the Assistant...'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isDiffVisible && (
|
||||
{isDiffVisible ? (
|
||||
<div className="flex justify-start p-0 border-t">
|
||||
<Button
|
||||
type="text"
|
||||
@@ -73,6 +85,27 @@ export const AskAIWidget = ({
|
||||
Reject <span className="text-xs text-foreground-light">Esc</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-start p-0 border-t">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
className="text-xs h-auto py-1 rounded-none px-3 border-r-border"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Generating...' : 'Generate'}{' '}
|
||||
{!isLoading && <span className="text-xs text-foreground-light">Enter</span>}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
type="text"
|
||||
className="text-xs h-auto py-1 rounded-none px-3 border-r-border"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel <span className="text-xs text-foreground-light">Esc</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<InlineWidgetProps>) => {
|
||||
const lineNumber = beforeLineNumber ?? afterLineNumber
|
||||
const key = `${id}-${lineNumber.toString()}`
|
||||
|
||||
const containerElement = useMemo(() => document.createElement('div'), [])
|
||||
const zoneIdRef = useRef<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<InlineWidget
|
||||
<ResizableAIWidget
|
||||
editor={diffEditorRef.current!}
|
||||
id="ask-ai-diff"
|
||||
heightInLines={3}
|
||||
afterLineNumber={0}
|
||||
beforeLineNumber={Math.max(0, promptState.startLineNumber - 1)}
|
||||
>
|
||||
<AskAIWidget
|
||||
onSubmit={(prompt: string) => {
|
||||
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}
|
||||
/>
|
||||
</InlineWidget>
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -741,27 +737,24 @@ export const SQLEditor = () => {
|
||||
}}
|
||||
/>
|
||||
{editorRef.current && promptState.isOpen && !isDiffOpen && (
|
||||
<InlineWidget
|
||||
<ResizableAIWidget
|
||||
editor={editorRef.current}
|
||||
id="ask-ai"
|
||||
afterLineNumber={promptState.endLineNumber}
|
||||
beforeLineNumber={Math.max(0, promptState.startLineNumber - 1)}
|
||||
heightInLines={2}
|
||||
>
|
||||
<AskAIWidget
|
||||
value={promptInput}
|
||||
onChange={setPromptInput}
|
||||
onSubmit={(prompt: string) => {
|
||||
handlePrompt(prompt, {
|
||||
beforeSelection: promptState.beforeSelection,
|
||||
selection: promptState.selection,
|
||||
afterSelection: promptState.afterSelection,
|
||||
})
|
||||
}}
|
||||
isDiffVisible={false}
|
||||
isLoading={isCompletionLoading}
|
||||
/>
|
||||
</InlineWidget>
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{!promptState.isOpen && !editorRef.current?.getValue() && (
|
||||
|
||||
@@ -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 (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
type="primary"
|
||||
size="tiny"
|
||||
iconRight={
|
||||
isExecuting ? (
|
||||
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
{os === 'macos' ? (
|
||||
<Command size={10} strokeWidth={1.5} />
|
||||
) : (
|
||||
<p className="text-xs text-foreground-light">CTRL</p>
|
||||
)}
|
||||
<CornerDownLeft size={10} strokeWidth={1.5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
className={className}
|
||||
>
|
||||
{hasSelection ? 'Run selected' : 'Run'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<RoleImpersonationPopover serviceRoleLabel="postgres" variant="connected-on-both" />
|
||||
<Button
|
||||
onClick={executeQuery}
|
||||
disabled={isDisabled || isExecuting}
|
||||
type="primary"
|
||||
size="tiny"
|
||||
iconRight={
|
||||
isExecuting ? (
|
||||
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
{os === 'macos' ? (
|
||||
<Command size={10} strokeWidth={1.5} />
|
||||
) : (
|
||||
<p className="text-xs text-foreground-light">CTRL</p>
|
||||
)}
|
||||
<CornerDownLeft size={10} strokeWidth={1.5} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<SqlRunButton
|
||||
isDisabled={isDisabled || isExecuting}
|
||||
isExecuting={isExecuting}
|
||||
className="rounded-l-none min-w-[82px]"
|
||||
>
|
||||
{hasSelection ? 'Run selected' : 'Run'}
|
||||
</Button>
|
||||
onClick={executeQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
id="editor-trigger"
|
||||
className="h-full w-full rounded-none text-foreground-light"
|
||||
onClick={() => {
|
||||
setEditorPanel({ open: !editorPanel.open })
|
||||
}}
|
||||
>
|
||||
<SqlEditor size={20} strokeWidth={1.5} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlineEditorButton
|
||||
@@ -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 = () => (
|
||||
<span className="text-border-stronger">
|
||||
<svg
|
||||
@@ -58,6 +61,7 @@ const LayoutHeader = ({
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const { mobileMenuOpen, setMobileMenuOpen } = useAppStateSnapshot()
|
||||
const isBranchingEnabled = selectedProject?.is_branch_enabled === true
|
||||
const isInlineEditorEnabled = useIsInlineEditorEnabled()
|
||||
|
||||
const { data: subscription } = useOrgSubscriptionQuery({
|
||||
orgSlug: selectedOrganization?.slug,
|
||||
@@ -163,8 +167,15 @@ const LayoutHeader = ({
|
||||
<div className="absolute md:hidden right-0 h-full w-3 bg-gradient-to-l from-background-dash-sidebar to-transparent pointer-events-none" />
|
||||
</div>
|
||||
{!!projectRef && (
|
||||
<div className="border-l flex-0 h-full">
|
||||
<AssistantButton />
|
||||
<div className="flex h-full items-center">
|
||||
{isInlineEditorEnabled && (
|
||||
<div className="border-l flex-0 h-full">
|
||||
<InlineEditorButton />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-l flex-0 h-full">
|
||||
<AssistantButton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement, PropsWithChildren<ProjectLayout
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
const selectedOrganization = useSelectedOrganization()
|
||||
const selectedProject = useSelectedProject()
|
||||
const { aiAssistantPanel, setAiAssistantPanel, mobileMenuOpen, setMobileMenuOpen } =
|
||||
useAppStateSnapshot()
|
||||
const {
|
||||
editorPanel,
|
||||
aiAssistantPanel,
|
||||
setAiAssistantPanel,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen,
|
||||
} = useAppStateSnapshot()
|
||||
const { open } = aiAssistantPanel
|
||||
|
||||
const projectName = selectedProject?.name
|
||||
@@ -210,7 +216,7 @@ const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayout
|
||||
)}
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
{isClient && aiAssistantPanel.open && (
|
||||
{isClient && (aiAssistantPanel.open || editorPanel.open) && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel
|
||||
@@ -221,7 +227,8 @@ const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayout
|
||||
'2xl:min-w-[500px] 2xl:max-w-[600px]'
|
||||
)}
|
||||
>
|
||||
<AIAssistantPanel />
|
||||
{aiAssistantPanel.open && <AIAssistantPanel />}
|
||||
{editorPanel.open && <EditorPanel />}
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<InlineWidget
|
||||
editor={editor}
|
||||
id={id}
|
||||
heightInLines={heightInLines}
|
||||
afterLineNumber={endLineNumber}
|
||||
beforeLineNumber={Math.max(0, startLineNumber - 1)}
|
||||
>
|
||||
<div ref={containerRef}>
|
||||
<AskAIWidget
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
onAccept={onAccept}
|
||||
onReject={onReject}
|
||||
onCancel={onCancel}
|
||||
isDiffVisible={isDiffVisible}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</InlineWidget>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResizableAIWidget
|
||||
@@ -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<monacoEditor.IStandaloneCodeEditor | null>(null)
|
||||
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(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 (
|
||||
<div className="flex-1 overflow-hidden flex flex-col h-full relative">
|
||||
{isDiffMode ? (
|
||||
<div className="w-full h-full">
|
||||
<DiffEditor
|
||||
theme="vs-dark"
|
||||
language={language}
|
||||
original={diffValue.original}
|
||||
modified={diffValue.modified}
|
||||
onMount={(editor: monacoEditor.IStandaloneDiffEditor) => {
|
||||
diffEditorRef.current = editor
|
||||
setIsDiffEditorMounted(true)
|
||||
}}
|
||||
options={{
|
||||
...defaultOptions,
|
||||
renderSideBySide: false,
|
||||
}}
|
||||
/>
|
||||
{isDiffEditorMounted && (
|
||||
<ResizableAIWidget
|
||||
editor={diffEditorRef.current!}
|
||||
id="ask-ai-diff"
|
||||
value={promptInput}
|
||||
onChange={setPromptInput}
|
||||
onSubmit={(prompt: string) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full relative">
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
language={language}
|
||||
value={currentValue}
|
||||
options={defaultOptions}
|
||||
onChange={(value: string | undefined) => {
|
||||
const newValue = value || ''
|
||||
setCurrentValue(newValue)
|
||||
onChange?.(newValue)
|
||||
}}
|
||||
onMount={handleEditorOnMount}
|
||||
className={className}
|
||||
/>
|
||||
{promptState.isOpen && editorRef.current && (
|
||||
<ResizableAIWidget
|
||||
editor={editorRef.current}
|
||||
id="ask-ai"
|
||||
value={promptInput}
|
||||
onChange={setPromptInput}
|
||||
onSubmit={(prompt: string) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{!promptState.isOpen && !currentValue && aiEndpoint && (
|
||||
<motion.p
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 5, opacity: 0 }}
|
||||
className="text-foreground-lighter absolute bottom-4 left-4 z-10 font-mono text-xs flex items-center gap-1"
|
||||
>
|
||||
Hit {os === 'macos' ? <Command size={12} /> : `CTRL+`}K to edit with the Assistant
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIEditor
|
||||
@@ -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
|
||||
|
||||
@@ -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<QueryResponseError>()
|
||||
const [results, setResults] = useState<undefined | any[]>(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<string>('')
|
||||
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 (
|
||||
<div className="flex flex-col h-full bg-surface-100">
|
||||
<div className="border-b flex shrink-0 items-center gap-x-3 px-5 h-[46px]">
|
||||
<span className="text-sm flex-1">SQL Editor</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
className="h-7"
|
||||
onClick={handleChat}
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
>
|
||||
Chat
|
||||
</Button>
|
||||
<ButtonTooltip
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'Save as snippet',
|
||||
},
|
||||
}}
|
||||
size="tiny"
|
||||
type="default"
|
||||
className="w-7 h-7"
|
||||
loading={isSaving}
|
||||
icon={<Save size={16} />}
|
||||
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(
|
||||
<div>
|
||||
Saved snippet! View it{' '}
|
||||
<InlineLink href={`/project/${ref}/sql/${snippet.id}`}>here</InlineLink>
|
||||
</div>
|
||||
)
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to create new query: ${error.message}`)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
className="w-7 h-7"
|
||||
onClick={() => setEditorPanel({ open: false })}
|
||||
icon={<X size={16} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<AIEditor
|
||||
language="pgsql"
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
aiEndpoint={`${BASE_PATH}/api/ai/sql/complete`}
|
||||
aiMetadata={{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
includeSchemaMetadata,
|
||||
}}
|
||||
initialPrompt={editorPanel.initialPrompt}
|
||||
options={{
|
||||
tabSize: 2,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
folding: false,
|
||||
padding: { top: 4 },
|
||||
lineNumbersMinChars: 3,
|
||||
}}
|
||||
executeQuery={onExecuteSql}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error !== undefined && (
|
||||
<div className="shrink-0">
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="m-0 rounded-none border-x-0 border-b-0 [&>div>div>pre]:text-sm [&>div]:flex [&>div]:flex-col [&>div]:gap-y-2"
|
||||
title={errorHeader || 'Error running SQL query'}
|
||||
description={
|
||||
<div>
|
||||
{errorContent.length > 0 ? (
|
||||
errorContent.map((errorText: string, i: number) => (
|
||||
<pre key={`err-${i}`} className="font-mono text-xs whitespace-pre-wrap">
|
||||
{errorText}
|
||||
</pre>
|
||||
))
|
||||
) : (
|
||||
<p className="font-mono text-xs">{error.error}</p>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWarning && (
|
||||
<SqlWarningAdmonition
|
||||
className="border-t"
|
||||
warningType={showWarning}
|
||||
onCancel={() => setShowWarning(undefined)}
|
||||
onConfirm={() => {
|
||||
setShowWarning(undefined)
|
||||
onExecuteSql(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{results !== undefined && results.length > 0 && (
|
||||
<div className={`max-h-72 shrink-0 flex flex-col`}>
|
||||
{showResults && (
|
||||
<div className="border-t flex-1 overflow-auto">
|
||||
<Results rows={results} />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-foreground-light border-t py-2 px-5 flex items-center justify-between">
|
||||
<span className="font-mono">
|
||||
{results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`}
|
||||
</span>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
className="ml-2"
|
||||
onClick={() => setShowResults((prev) => !prev)}
|
||||
>
|
||||
{showResults ? 'Hide Results' : 'Show Results'}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{results !== undefined && results.length === 0 && (
|
||||
<div className="shrink-0">
|
||||
<p className="text-xs text-foreground-light font-mono py-2 px-5">
|
||||
Success. No rows returned.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTemplates && editorPanel.templates && (
|
||||
<div className="bg-surface-100 border-t w-full flex flex-col max-h-80 h-full text-sm">
|
||||
<div className="px-4 py-3 border-b shrink-0">
|
||||
<Input_Shadcn_
|
||||
placeholder="Search templates..."
|
||||
value={templateSearch}
|
||||
onChange={(e) => setTemplateSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1 p-2">
|
||||
{editorPanel.templates
|
||||
?.filter((template) => {
|
||||
const searchLower = templateSearch.toLowerCase()
|
||||
return (
|
||||
template.name.toLowerCase().includes(searchLower) ||
|
||||
template.description.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
?.map((template, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="cursor-pointer group rounded-lg flex items-center gap-4 px-4 py-3 hover:bg-surface-200"
|
||||
onClick={() => onSelectTemplate(template.content)}
|
||||
onMouseEnter={() => handleTemplateMouseEnter(template.content)}
|
||||
onMouseLeave={handleTemplateMouseLeave}
|
||||
>
|
||||
<SQL_ICON
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
className={cn(
|
||||
'transition-colors fill-foreground-muted group-aria-selected:fill-foreground',
|
||||
'w-5 h-5 shrink-0 grow-0 -ml-0.5'
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs mb-1">{template.name}</p>
|
||||
<p className="text-xs text-foreground-light">{template.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-surface-100 flex items-center gap-2 !justify-between px-5 py-4 w-full border-t shrink-0">
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
onClick={() => setShowTemplates(!showTemplates)}
|
||||
icon={<Book size={14} />}
|
||||
>
|
||||
{showTemplates ? 'Templates' : 'Templates'}
|
||||
</Button>
|
||||
<SqlRunButton isDisabled={isExecuting} isExecuting={isExecuting} onClick={onExecuteSql} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorPanel
|
||||
@@ -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<typeof TooltipContent> & { text: string } } = {
|
||||
content: { side: 'bottom', text: 'Edit in SQL Editor' },
|
||||
}
|
||||
@@ -54,9 +66,9 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
|
||||
return (
|
||||
<ButtonTooltip
|
||||
asChild
|
||||
type="text"
|
||||
type={type}
|
||||
size="tiny"
|
||||
className="w-7 h-7"
|
||||
className={cn('w-7 h-7', className)}
|
||||
icon={<Edit size={14} />}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
@@ -67,12 +79,19 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
|
||||
|
||||
return !isInSQLEditor || isInNewSnippet ? (
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
type={type}
|
||||
size="tiny"
|
||||
className="w-7 h-7"
|
||||
className={cn('w-7 h-7', className)}
|
||||
icon={<Edit size={14} />}
|
||||
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) => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
type={type}
|
||||
size="tiny"
|
||||
disabled={!sql}
|
||||
className="w-7 h-7"
|
||||
className={cn('w-7 h-7', className)}
|
||||
icon={<Edit size={14} />}
|
||||
tooltip={!!sql ? tooltip : { content: { side: 'bottom', text: undefined } }}
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="mb-0 rounded-none border-0 shrink-0 bg-background-100"
|
||||
>
|
||||
<p>
|
||||
{showWarning === 'hasWriteOperation'
|
||||
? 'This query contains write operations.'
|
||||
: 'This query involves running a function.'}{' '}
|
||||
Are you sure you want to execute it?
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
Make sure you are not accidentally removing something important.
|
||||
</p>
|
||||
<div className="flex justify-stretch mt-2 gap-2">
|
||||
<Button
|
||||
type="outline"
|
||||
size="tiny"
|
||||
className="w-full flex-1"
|
||||
onClick={() => setShowWarning(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
size="tiny"
|
||||
disabled={!sql}
|
||||
className="w-full flex-1"
|
||||
onClick={() => {
|
||||
// [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')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</Admonition>
|
||||
<SqlWarningAdmonition
|
||||
warningType={showWarning}
|
||||
className="border-b"
|
||||
onCancel={() => 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 && (
|
||||
|
||||
@@ -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 (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className={`mb-0 rounded-none border-0 shrink-0 bg-background-100 ${className}`}
|
||||
>
|
||||
<p>
|
||||
{warningType === 'hasWriteOperation'
|
||||
? 'This query contains write operations.'
|
||||
: 'This query involves running a function.'}{' '}
|
||||
Are you sure you want to execute it?
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
Make sure you are not accidentally removing something important.
|
||||
</p>
|
||||
<div className="flex justify-stretch mt-2 gap-2">
|
||||
<Button type="outline" size="tiny" className="w-full flex-1" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="danger"
|
||||
size="tiny"
|
||||
disabled={disabled}
|
||||
className="w-full flex-1"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
|
||||
export default SqlWarningAdmonition
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>()
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<DatabaseFunction | undefined>()
|
||||
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) => {
|
||||
|
||||
@@ -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<any>()
|
||||
const [showCreateTriggerForm, setShowCreateTriggerForm] = useState<boolean>(false)
|
||||
const [showDeleteTriggerForm, setShowDeleteTriggerForm] = useState<boolean>(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) => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -1,8 +1,9 @@
|
||||
import type { Message as MessageType } from 'ai/react'
|
||||
import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
|
||||
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
|
||||
import { proxy, snapshot, subscribe, useSnapshot } from 'valtio'
|
||||
import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries'
|
||||
|
||||
export type CommonDatabaseEntity = {
|
||||
id: number
|
||||
@@ -16,6 +17,12 @@ export type SuggestionsType = {
|
||||
prompts?: string[]
|
||||
}
|
||||
|
||||
export type Template = {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
||||
type AiAssistantPanelType = {
|
||||
open: boolean
|
||||
messages: MessageType[]
|
||||
@@ -30,6 +37,17 @@ type AiAssistantPanelType = {
|
||||
tables: { schema: string; name: string }[]
|
||||
}
|
||||
|
||||
type EditorPanelType = {
|
||||
open: boolean
|
||||
initialValue?: string
|
||||
label?: string
|
||||
saveLabel?: string
|
||||
onSave?: (value: string) => 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<AiAssistantPanelType>) => {
|
||||
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<EditorPanelType>) => {
|
||||
// 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)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HTM
|
||||
*/
|
||||
const ExpandingTextArea = forwardRef<HTMLTextAreaElement, ExpandingTextAreaProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(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 (
|
||||
<TextArea
|
||||
ref={textAreaRef}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
internalRef.current = element
|
||||
updateTextAreaHeight(element)
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
aria-expanded={false}
|
||||
className={cn('transition-all resize-none leading-6 box-border', className)}
|
||||
className={cn('h-auto resize-none box-border', className)}
|
||||
value={value}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user