diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 76a46f5374a..e5f665a77d4 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1016,6 +1016,12 @@ importers: react-markdown: specifier: ~10.1.0 version: 10.1.0(@types/react@18.2.0)(react@18.2.0) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 devDependencies: '@storybook/react': specifier: ^6.5.16 diff --git a/workspaces/ballerina/ballerina-extension/grammar/ballerina-grammar b/workspaces/ballerina/ballerina-extension/grammar/ballerina-grammar index 10325fc5436..eb62358724d 160000 --- a/workspaces/ballerina/ballerina-extension/grammar/ballerina-grammar +++ b/workspaces/ballerina/ballerina-extension/grammar/ballerina-grammar @@ -1 +1 @@ -Subproject commit 10325fc5436a87606407ba68bf668bcd59dc180d +Subproject commit eb62358724deaad784458ae1d5ec33c4d164e483 diff --git a/workspaces/ballerina/ballerina-side-panel/package.json b/workspaces/ballerina/ballerina-side-panel/package.json index 04324c9a4dd..1c0db97214a 100644 --- a/workspaces/ballerina/ballerina-side-panel/package.json +++ b/workspaces/ballerina/ballerina-side-panel/package.json @@ -28,6 +28,8 @@ "lodash": "~4.17.21", "react-hook-form": "7.56.4", "react-markdown": "~10.1.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", "@github/markdown-toolbar-element": "^2.2.3", "@codemirror/commands": "~6.10.0", "@codemirror/state": "~6.5.2", diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts b/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts index 0c387c23ce5..d74111308e8 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts @@ -20,6 +20,7 @@ import { RefObject } from "react"; import { DiagnosticMessage, FormDiagnostics, TextEdit, PropertyModel, LinePosition, LineRange, ExpressionProperty, Metadata, RecordTypeField, Imports, ConfigProperties } from "@wso2/ballerina-core"; import { ParamConfig } from "../ParamManager/ParamManager"; import { CompletionItem, FormExpressionEditorRef, HelperPaneHeight, HelperPaneOrigin, OptionProps } from "@wso2/ui-toolkit"; +import { InputMode } from "../editors/MultiModeExpressionEditor/ChipExpressionEditor/types"; export type FormValues = { [key: string]: any; @@ -184,7 +185,8 @@ type FormHelperPaneConditionalProps = { helperPaneHeight: HelperPaneHeight, recordTypeField?: RecordTypeField, isAssignIdentifier?: boolean, - valueTypeConstraint?: string | string[] + valueTypeConstraint?: string | string[], + inputMode?: InputMode ) => JSX.Element; helperPaneOrigin?: HelperPaneOrigin; helperPaneHeight: HelperPaneHeight; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/ModeSwitcher/styles.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/ModeSwitcher/styles.tsx index ba06bcb2910..872f1e0c11e 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/ModeSwitcher/styles.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/ModeSwitcher/styles.tsx @@ -94,7 +94,8 @@ export const SwitchWrapper = styled.div` position: relative; display: inline-flex; align-items: center; - width: 112px; + min-width: 112px; + width: max-content; height: 24px; margin-top: 2px; `; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/EditorFactory.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/EditorFactory.tsx index 85ca9aa36af..b66c118a6e7 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/EditorFactory.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/EditorFactory.tsx @@ -155,10 +155,9 @@ export const EditorFactory = (props: FormFieldEditorProps) => { /> ); - } else if (!field.items && (field.type === "EXPRESSION" || field.type === "LV_EXPRESSION" || field.type == "ACTION_OR_EXPRESSION") && field.editable) { - // Expression field is a inline expression editor + } else if (!field.items && (field.type === "RAW_TEMPLATE" || field.valueTypeConstraint === "ai:Prompt") && field.editable) { return ( - { recordTypeField={recordTypeFields?.find(recordField => recordField.key === field.key)} /> ); - } else if (!field.items && field.type === "RAW_TEMPLATE" && field.editable) { + } else if (!field.items && (field.type === "EXPRESSION" || field.type === "LV_EXPRESSION" || field.type == "ACTION_OR_EXPRESSION") && field.editable) { + // Expression field is a inline expression editor return ( - recordField.key === field.key)} /> ); } else if (field.type === "VIEW") { diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx index 838868b3bdd..23d55da3357 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx @@ -25,8 +25,12 @@ import { EditorMode } from "./modes/types"; import { TextMode } from "./modes/TextMode"; import { PromptMode } from "./modes/PromptMode"; import { ExpressionMode } from "./modes/ExpressionMode"; +import { TemplateMode } from "./modes/TemplateMode"; import { MinimizeIcon } from "../MultiModeExpressionEditor/ChipExpressionEditor/components/FloatingButtonIcons"; import { LineRange } from "@wso2/ballerina-core/lib/interfaces/common"; +import { DiagnosticMessage } from "@wso2/ballerina-core"; +import { InputMode } from "../MultiModeExpressionEditor/ChipExpressionEditor/types"; +import { FieldError } from "react-hook-form"; interface ExpandedPromptEditorProps { isOpen: boolean; @@ -41,6 +45,8 @@ interface ExpandedPromptEditorProps { completions?: CompletionItem[]; fileName?: string; targetLineRange?: LineRange; + sanitizedExpression?: (value: string) => string; + rawExpression?: (value: string) => string; extractArgsFromFunction?: (value: string, cursorPosition: number) => Promise<{ label: string; args: string[]; @@ -52,6 +58,9 @@ interface ExpandedPromptEditorProps { onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, helperPaneHeight: HelperPaneHeight ) => React.ReactNode; + // Error diagnostics props + error?: FieldError; + formDiagnostics?: DiagnosticMessage[]; } const ModalContainer = styled.div` @@ -97,7 +106,7 @@ const ModalHeaderSection = styled.header` const ModalContent = styled.div` flex: 1; - overflow-y: auto; + overflow-y: hidden; padding: 8px 18px 16px; display: flex; flex-direction: column; @@ -139,7 +148,8 @@ const TitleWrapper = styled.div` const MODE_COMPONENTS: Record> = { text: TextMode, prompt: PromptMode, - expression: ExpressionMode + expression: ExpressionMode, + template: TemplateMode }; export const ExpandedEditor: React.FC = ({ @@ -153,8 +163,12 @@ export const ExpandedEditor: React.FC = ({ completions, fileName, targetLineRange, + sanitizedExpression, + rawExpression, extractArgsFromFunction, - getHelperPane + getHelperPane, + error, + formDiagnostics }) => { const promptFields = ["query", "instructions", "role"]; @@ -163,10 +177,14 @@ export const ExpandedEditor: React.FC = ({ promptFields.includes(field.key) ? "prompt" : "text" ); - const [mode] = useState(defaultMode); + const [mode, setMode] = useState(defaultMode); const [showPreview, setShowPreview] = useState(false); const [mouseDownTarget, setMouseDownTarget] = useState(null); + useEffect(() => { + setMode(defaultMode); + }, [defaultMode]); + useEffect(() => { if (mode === "text") { setShowPreview(false); @@ -202,15 +220,33 @@ export const ExpandedEditor: React.FC = ({ // Props for modes with preview support ...(mode === "prompt" && { isPreviewMode: showPreview, - onTogglePreview: () => setShowPreview(!showPreview) + onTogglePreview: (enabled: boolean) => setShowPreview(enabled) }), // Props for expression mode ...(mode === "expression" && { completions, fileName, targetLineRange, + sanitizedExpression, + rawExpression, + extractArgsFromFunction, + getHelperPane, + error, + formDiagnostics + }), + // Props for template mode + ...(mode === "template" && { + completions, + fileName, + targetLineRange, + sanitizedExpression, + rawExpression, extractArgsFromFunction, - getHelperPane + getHelperPane, + isPreviewMode: showPreview, + onTogglePreview: (enabled: boolean) => setShowPreview(enabled), + error, + formDiagnostics }) }; // HACK: Must find a proper central way to manager popups diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/ChipComponent.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/ChipComponent.tsx new file mode 100644 index 00000000000..7121af0b8f2 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/ChipComponent.tsx @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from "react"; + +import { DocumentType, TokenType } from "../../MultiModeExpressionEditor/ChipExpressionEditor/types"; +import { + getChipDisplayContent, + StandardChip, + ChipText, + getTokenIconClass, + StandardIcon +} from "../../MultiModeExpressionEditor/ChipExpressionEditor/chipStyles"; + +export interface ChipComponentProps { + type: TokenType; + content: string; + documentType?: DocumentType; +} + +export const ChipComponent: React.FC = ({ type, content, documentType }) => { + let iconClass = getTokenIconClass(type, documentType); + return ( + + + {getChipDisplayContent(type, content)} + + ); +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/MarkdownPreview.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/MarkdownPreview.tsx index e3404efe75a..7429841c27b 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/MarkdownPreview.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/MarkdownPreview.tsx @@ -20,15 +20,17 @@ import React from "react"; import styled from "@emotion/styled"; import { ThemeColors } from "@wso2/ui-toolkit"; import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; +import { ChipComponent } from "./ChipComponent"; +import { DocumentType, TokenType } from "../../MultiModeExpressionEditor/ChipExpressionEditor/types"; +import "../styles/markdown-preview.css"; const PreviewContainer = styled.div` width: 100%; height: 100%; - padding: 12px; - fontSize: 14px; - font-family: var(--vscode-editor-font-family); + padding: 16px; background: var(--input-background); - color: ${ThemeColors.ON_SURFACE}; border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; border-top: none; border-radius: 0 0 4px 4px; @@ -36,24 +38,10 @@ const PreviewContainer = styled.div` overflow-x: auto; box-sizing: border-box; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: break-word; - - p, li, td, th, blockquote { - word-wrap: break-word; - overflow-wrap: break-word; - } - - pre { - overflow-x: auto; - white-space: pre-wrap; - word-wrap: break-word; - } - - code { - white-space: pre-wrap; - word-wrap: break-word; + .markdown-body { + background: transparent; + font-size: 14px; + color: ${ThemeColors.ON_SURFACE}; } `; @@ -68,17 +56,31 @@ interface MarkdownPreviewProps { export const MarkdownPreview: React.FC = ({ content }) => { return ( - null, - iframe: () => null, - }} - disallowedElements={['script', 'iframe', 'object', 'embed']} - unwrapDisallowed={true} - > - {content} - +
+ null, + iframe: () => null, + // Custom chip component for rendering tokens + chip: ({ node }: any) => { + const props = node?.properties || {}; + return ( + + ); + } + }} + disallowedElements={['script', 'iframe', 'object', 'embed']} + unwrapDisallowed={true} + > + {content} + +
); }; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/TemplateMarkdownToolbar.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/TemplateMarkdownToolbar.tsx new file mode 100644 index 00000000000..12cc21662d7 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/TemplateMarkdownToolbar.tsx @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from "react"; +import styled from "@emotion/styled"; +import { ThemeColors, Icon, Switch } from "@wso2/ui-toolkit"; +import { EditorView } from "@codemirror/view"; +import { + insertMarkdownFormatting, + insertMarkdownHeader, + insertMarkdownLink, + insertMarkdownBlockquote, + insertMarkdownUnorderedList, + insertMarkdownOrderedList, + insertMarkdownTaskList +} from "../utils/templateUtils"; +import { HelperPaneToggleButton } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton"; + +const ToolbarContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 8px 12px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 4px 4px 0 0; + flex-wrap: wrap; + font-family: GilmerMedium; +`; + +const ToolbarButtonGroup = styled.div` + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +`; + +const ToolbarButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background-color: transparent; + color: ${ThemeColors.ON_SURFACE}; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + border-color: ${ThemeColors.OUTLINE}; + } + + &:active:not(:disabled) { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${ThemeColors.PRIMARY}; + outline-offset: 2px; + } +`; + +const ToolbarDivider = styled.div` + width: 1px; + height: 24px; + background-color: ${ThemeColors.OUTLINE_VARIANT}; + margin: 0 4px; +`; + +interface TemplateMarkdownToolbarProps { + editorView: EditorView | null; + isPreviewMode?: boolean; + onTogglePreview?: () => void; + helperPaneToggle?: { + ref: React.RefObject; + isOpen: boolean; + onClick: () => void; + }; +} + +export const TemplateMarkdownToolbar = React.forwardRef(({ + editorView, + isPreviewMode = false, + onTogglePreview, + helperPaneToggle +}, ref) => { + // Prevent buttons from taking focus away from the editor + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + const handleBold = () => insertMarkdownFormatting(editorView, '**'); + const handleItalic = () => insertMarkdownFormatting(editorView, '_'); + const handleCode = () => insertMarkdownFormatting(editorView, '`'); + const handleLink = () => insertMarkdownLink(editorView); + const handleHeader = () => insertMarkdownHeader(editorView, 3); + const handleQuote = () => insertMarkdownBlockquote(editorView); + const handleUnorderedList = () => insertMarkdownUnorderedList(editorView); + const handleOrderedList = () => insertMarkdownOrderedList(editorView); + const handleTaskList = () => insertMarkdownTaskList(editorView); + + return ( + + + {helperPaneToggle && ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {onTogglePreview && ( + + )} + + ); +}); + +TemplateMarkdownToolbar.displayName = 'TemplateMarkdownToolbar'; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx index 8a980f79aa3..42fad8c2b02 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx @@ -20,6 +20,7 @@ import React from "react"; import styled from "@emotion/styled"; import { EditorModeExpressionProps } from "./types"; import { ChipExpressionEditorComponent } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; +import { ErrorBanner } from "@wso2/ui-toolkit"; const ExpressionContainer = styled.div` width: 100%; @@ -38,8 +39,12 @@ export const ExpressionMode: React.FC = ({ completions = [], fileName, targetLineRange, + sanitizedExpression, extractArgsFromFunction, - getHelperPane + getHelperPane, + rawExpression, + error, + formDiagnostics }) => { // Convert onChange signature from (value: string) => void to (value: string, cursorPosition: number) => void const handleChange = (updatedValue: string, updatedCursorPosition: number) => { @@ -47,18 +52,27 @@ export const ExpressionMode: React.FC = ({ }; return ( - - - + <> + + + + {error ? + : + formDiagnostics && formDiagnostics.length > 0 && + d.message).join(', ')} /> + } + ); }; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/TemplateMode.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/TemplateMode.tsx new file mode 100644 index 00000000000..2213b4f3bbe --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/TemplateMode.tsx @@ -0,0 +1,163 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useState, useRef } from "react"; +import styled from "@emotion/styled"; +import { EditorView } from "@codemirror/view"; +import { EditorModeExpressionProps } from "./types"; +import { ChipExpressionEditorComponent } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; +import { TemplateMarkdownToolbar } from "../controls/TemplateMarkdownToolbar"; +import { MarkdownPreview } from "../controls/MarkdownPreview"; +import { transformExpressionToMarkdown } from "../utils/transformToMarkdown"; +import { useFormContext } from "../../../../context/form"; +import { ErrorBanner } from "@wso2/ui-toolkit"; + +const ExpressionContainer = styled.div` + width: 100%; + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; + overflow: hidden; +`; + +export const TemplateMode: React.FC = ({ + value, + onChange, + completions = [], + fileName, + targetLineRange, + sanitizedExpression, + extractArgsFromFunction, + getHelperPane, + rawExpression, + isPreviewMode = false, + onTogglePreview, + error, + formDiagnostics +}) => { + const [transformedContent, setTransformedContent] = useState(""); + const [editorView, setEditorView] = useState(null); + const [helperPaneToggle, setHelperPaneToggle] = useState<{ + ref: React.RefObject; + isOpen: boolean; + onClick: () => void; + } | null>(null); + const toolbarRef = useRef(null); + const { expressionEditor } = useFormContext(); + const expressionEditorRpcManager = expressionEditor?.rpcManager; + + // Convert onChange signature from (value: string) => void to (value: string, cursorPosition: number) => void + const handleChange = (updatedValue: string, updatedCursorPosition: number) => { + onChange(updatedValue, updatedCursorPosition); + }; + + // Transform expression to markdown when entering preview mode + useEffect(() => { + const transformContent = async () => { + if (isPreviewMode && value && expressionEditorRpcManager) { + try { + // Fetch token stream from language server + const startLine = targetLineRange?.startLine; + const tokenStream = await expressionEditorRpcManager.getExpressionTokens( + value, + fileName, + startLine !== undefined ? startLine : undefined + ); + + // Get sanitized value for display + const displayValue = sanitizedExpression ? sanitizedExpression(value) : value; + + if (tokenStream && tokenStream.length > 0) { + // Transform expression with tokens to markdown with chip tags + const markdown = transformExpressionToMarkdown(displayValue, tokenStream); + setTransformedContent(markdown); + } else { + // No tokens, use sanitized value as-is + setTransformedContent(displayValue); + } + } catch (error) { + console.error('Error transforming expression to markdown:', error); + // Fallback to sanitized value on error + const displayValue = sanitizedExpression ? sanitizedExpression(value) : value; + setTransformedContent(displayValue); + } + } + }; + + transformContent(); + }, [isPreviewMode, value, fileName, targetLineRange?.startLine, expressionEditorRpcManager, sanitizedExpression]); + + // Only show toolbar and preview if preview props are provided + const hasPreviewSupport = onTogglePreview !== undefined; + + const handleHelperPaneStateChange = (state: { + isOpen: boolean; + ref: React.RefObject; + toggle: () => void; + }) => { + setHelperPaneToggle({ + ref: state.ref, + isOpen: state.isOpen, + onClick: state.toggle + }); + }; + + return ( + <> + {hasPreviewSupport && ( + onTogglePreview(!isPreviewMode)} + helperPaneToggle={helperPaneToggle || undefined} + /> + )} + {isPreviewMode ? ( + + ) : ( + + + + )} + {error ? + : + formDiagnostics && formDiagnostics.length > 0 && + d.message).join(', ')} /> + } + + ); +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts index 88f6bb79182..ce2ca564be5 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts @@ -19,6 +19,8 @@ import { FormField, HelperpaneOnChangeOptions } from "../../../Form/types"; import { CompletionItem, FnSignatureDocumentation, HelperPaneHeight } from "@wso2/ui-toolkit"; import { LineRange } from "@wso2/ballerina-core/lib/interfaces/common"; +import { DiagnosticMessage } from "@wso2/ballerina-core"; +import { FieldError } from "react-hook-form"; /** * Base props that all editor mode components must implement @@ -52,6 +54,9 @@ export interface EditorModeExpressionProps extends EditorModeProps { fileName?: string; /** Target line range for context */ targetLineRange?: LineRange; + /** Optional function to sanitize expression for display (e.g., remove backticks) */ + sanitizedExpression?: (value: string) => string; + rawExpression?: (value: string) => string; /** Function to extract arguments from function calls */ extractArgsFromFunction?: (value: string, cursorPosition: number) => Promise<{ label: string; @@ -65,18 +70,17 @@ export interface EditorModeExpressionProps extends EditorModeProps { onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, helperPaneHeight: HelperPaneHeight ) => React.ReactNode; + /** Whether preview mode is active */ + isPreviewMode?: boolean; + /** Callback to toggle preview mode */ + onTogglePreview?: (enabled: boolean) => void; + /** Form validation error */ + error?: FieldError; + /** Form diagnostics messages */ + formDiagnostics?: DiagnosticMessage[]; } /** * Mode type identifier */ -export type EditorMode = "text" | "prompt" | "expression"; - -/** - * Map of mode identifiers to their display labels - */ -export const MODE_LABELS: Record = { - text: "Text", - prompt: "Prompt", - expression: "Expression" -}; +export type EditorMode = "text" | "prompt" | "expression" | "template"; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/styles/markdown-preview.css b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/styles/markdown-preview.css new file mode 100644 index 00000000000..e8a57c6f73c --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/styles/markdown-preview.css @@ -0,0 +1,580 @@ +.markdown-body { + color-scheme: light; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: #1f2328; + background-color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: #0969da; + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: 600; +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: 600; + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid #d1d9e0b3; +} + +.markdown-body mark { + background-color: #fff8c5; + color: #1f2328; +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 2.5rem; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid #d1d9e0b3; + height: .25em; + padding: 0; + margin: 1.5rem 0; + background-color: #d1d9e0; + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: #59636e; + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + font-variant: tabular-nums; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid #0969da; + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid #0969da; + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 0.25rem; + font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + line-height: 10px; + color: #1f2328; + vertical-align: middle; + background-color: #f6f8fa; + border: solid 1px #d1d9e0b3; + border-bottom-color: #d1d9e0b3; + border-radius: 6px; + box-shadow: inset 0 -1px 0 #d1d9e0b3; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 1.5rem; + margin-bottom: 1rem; + font-weight: 600; + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: 600; + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid #d1d9e0b3; +} + +.markdown-body h3 { + font-weight: 600; + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: 600; + font-size: 1em; +} + +.markdown-body h5 { + font-weight: 600; + font-size: .875em; +} + +.markdown-body h6 { + font-weight: 600; + font-size: .85em; + color: #59636e; +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: #59636e; + border-left: .25em solid #d1d9e0; +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + font-size: 12px; + word-wrap: normal; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 1rem; +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: 1rem; +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 1rem; + font-size: 1em; + font-style: italic; + font-weight: 600; +} + +.markdown-body dl dd { + padding: 0 1rem; + margin-bottom: 1rem; +} + +.markdown-body table th { + font-weight: 600; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #d1d9e0; +} + +.markdown-body table td>:last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + border-top: 1px solid #d1d9e0b3; +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: #818b981f; + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body body:has(:modal) { + padding-right: var(--dialog-scrollgutter) !important; +} + +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), +.markdown-body button:focus:not(:focus-visible), +.markdown-body summary:focus:not(:focus-visible), +.markdown-body a:focus:not(:focus-visible) { + outline: none; + box-shadow: none; +} + +.markdown-body [tabindex="0"]:focus:not(:focus-visible), +.markdown-body details-dialog:focus:not(:focus-visible) { + outline: none; +} + +.markdown-body ul:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body ol:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} \ No newline at end of file diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/templateUtils.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/templateUtils.ts new file mode 100644 index 00000000000..b7d4ea6320e --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/templateUtils.ts @@ -0,0 +1,353 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EditorView, KeyBinding } from "@codemirror/view"; + +/** + * Inserts or removes markdown formatting around selected text (toggles) + */ +export const insertMarkdownFormatting = ( + view: EditorView | null, + prefix: string, + suffix: string = prefix +) => { + if (!view) return; + + const { from, to } = view.state.selection.main; + const selectedText = view.state.sliceDoc(from, to); + + // Check if the selection itself is wrapped + const isInternallyFormatted = + selectedText.startsWith(prefix) && + selectedText.endsWith(suffix) && + selectedText.length >= prefix.length + suffix.length; + + if (isInternallyFormatted) { + // Unwrap selection + const newText = selectedText.slice(prefix.length, selectedText.length - suffix.length); + view.dispatch({ + changes: { from, to, insert: newText }, + selection: { anchor: from, head: from + newText.length } + }); + view.focus(); + return; + } + + // Check if the surrounding text is wrapped + const beforeSelection = view.state.sliceDoc(Math.max(0, from - prefix.length), from); + const afterSelection = view.state.sliceDoc(to, Math.min(view.state.doc.length, to + suffix.length)); + + if (beforeSelection === prefix && afterSelection === suffix) { + // Unwrap surrounding + view.dispatch({ + changes: [ + { from: from - prefix.length, to: from, insert: '' }, + { from: to, to: to + suffix.length, insert: '' } + ], + selection: { anchor: from - prefix.length, head: to - prefix.length } + }); + } else { + // Wrap selection + const newText = `${prefix}${selectedText}${suffix}`; + view.dispatch({ + changes: { from, to, insert: newText }, + selection: { anchor: from + prefix.length, head: from + prefix.length + selectedText.length } + }); + } + + view.focus(); +}; + +/** + * Toggles markdown header at the current line + */ +export const insertMarkdownHeader = (view: EditorView | null, level: number = 3) => { + if (!view) return; + + const { from } = view.state.selection.main; + const line = view.state.doc.lineAt(from); + const match = line.text.match(/^(#{1,6})\s*/); + + const existingLevel = match ? match[1].length : 0; + const cleanText = match ? line.text.slice(match[0].length) : line.text; + + // If same level, toggle off (remove). Otherwise, update level. + const newText = existingLevel === level ? cleanText : '#'.repeat(level) + ' ' + cleanText; + + view.dispatch({ + changes: { from: line.from, to: line.to, insert: newText }, + selection: { anchor: line.from + newText.length } + }); + + view.focus(); +}; + +/** + * Toggles markdown link + */ +export const insertMarkdownLink = (view: EditorView | null) => { + if (!view) return; + + const { from, to } = view.state.selection.main; + const selectedText = view.state.sliceDoc(from, to); + const linkMatch = selectedText.match(/^\[(.+?)\]\((.+?)\)$/); + + if (linkMatch) { + // Unwrap existing link: [text](url) -> text + const linkText = linkMatch[1]; + view.dispatch({ + changes: { from, to, insert: linkText }, + selection: { anchor: from, head: from + linkText.length } + }); + } else { + // Check surrounding context for existing link + const before = view.state.sliceDoc(Math.max(0, from - 1), from); + const afterStart = view.state.sliceDoc(to, Math.min(view.state.doc.length, to + 2)); + + // Simple heuristic for surrounding link + if (before === '[' && afterStart.startsWith('](')) { + const textAfter = view.state.sliceDoc(to, Math.min(view.state.doc.length, to + 200)); + const urlMatch = textAfter.match(/^\]\((.+?)\)/); + + if (urlMatch) { + const urlLen = urlMatch[0].length; + view.dispatch({ + changes: [ + { from: from - 1, to: from, insert: '' }, + { from: to, to: to + urlLen - 1, insert: '' } // -1 to keep the ')' logic aligned + ], + selection: { anchor: from - 1, head: to - 1 } + }); + view.focus(); + return; + } + } + + // Create new link + const label = selectedText || 'link text'; + const url = 'url'; + view.dispatch({ + changes: { from, to, insert: `[${label}](${url})` }, + selection: { + // Highlight the URL portion + anchor: from + label.length + 3, + head: from + label.length + 3 + url.length + } + }); + } + view.focus(); +}; + +/** + * Toggles markdown blockquote + */ +export const insertMarkdownBlockquote = (view: EditorView | null) => { + if (!view) return; + + const { from, to } = view.state.selection.main; + const selection = view.state.sliceDoc(from, to); + + // Handle both single line (cursor only) and multiline selection + // If cursor is just on a line, treat it as that line being selected + let workingSelection = selection; + let startPos = from; + + if (from === to) { + const line = view.state.doc.lineAt(from); + workingSelection = line.text; + startPos = line.from; + } + + const lines = workingSelection.split('\n'); + const allQuoted = lines.every(l => l.trim() === '' || l.startsWith('> ')); + + const newLines = lines.map(line => { + if (allQuoted) return line.startsWith('> ') ? line.slice(2) : line; + return line.startsWith('> ') ? line : `> ${line}`; + }); + + const insert = newLines.join('\n'); + + view.dispatch({ + changes: { from: startPos, to: startPos + workingSelection.length, insert }, + selection: { anchor: startPos, head: startPos + insert.length } + }); + + view.focus(); +}; + +// --- List Logic --- + +type ListConfig = { + isListed: (trimmed: string) => boolean; + strip: (trimmed: string) => string; + add: (trimmed: string, index: number) => string; +}; + +const toggleList = (view: EditorView | null, config: ListConfig) => { + if (!view) return; + + const { from, to } = view.state.selection.main; + const selection = view.state.sliceDoc(from, to); + + const isMultiLine = selection.includes("\n"); + const hasSelection = from !== to; // Check if user actually selected text + + // If single line/cursor, expand to full line content + // Note: We use the line boundaries for calculation but keep track of original 'from' + let lines = isMultiLine ? selection.split("\n") : [view.state.doc.lineAt(from).text]; + let startOffset = isMultiLine ? from : view.state.doc.lineAt(from).from; + + const allListed = lines.every(line => { + const trimmed = line.trim(); + return trimmed === "" || config.isListed(trimmed); + }); + + const processedLines = lines.map((line, index) => { + const indent = line.match(/^\s*/)?.[0] ?? ""; + const trimmed = line.trim(); + + if (allListed) { + // Strip formatting + return config.isListed(trimmed) ? indent + config.strip(trimmed) : line; + } + // Add formatting + if (config.isListed(trimmed)) return line; + return indent + config.add(trimmed, index); + }); + + const insert = processedLines.join("\n"); + const endOffset = startOffset + insert.length; + + view.dispatch({ + changes: { + from: startOffset, + to: startOffset + (isMultiLine ? selection.length : lines[0].length), + insert + }, + selection: hasSelection + ? { anchor: startOffset, head: endOffset } // Preserve selection range for toggling + : { anchor: endOffset } // Move cursor to end for typing + }); + + view.focus(); +}; + +export const insertMarkdownUnorderedList = (view: EditorView | null) => { + toggleList(view, { + isListed: t => t.startsWith("- ") || t.startsWith("* "), + strip: t => t.replace(/^[-*]\s/, ""), + add: t => `- ${t}` + }); +}; + +export const insertMarkdownOrderedList = (view: EditorView | null) => { + toggleList(view, { + isListed: t => /^\d+\.\s/.test(t), + strip: t => t.replace(/^\d+\.\s/, ""), + add: (t, i) => `${i + 1}. ${t}` + }); +}; + +export const insertMarkdownTaskList = (view: EditorView | null) => { + toggleList(view, { + isListed: t => /^-\s\[[ x]\]\s/.test(t), + strip: t => t.replace(/^-\s\[[ x]\]\s/, ""), + add: t => `- [ ] ${t}` + }); +}; + +// --- List Continuation on Enter --- + +interface ListPattern { + regex: RegExp; + nextMarker: (match: RegExpMatchArray) => string; +} + +const LIST_PATTERNS: ListPattern[] = [ + { + // Task List: "- [ ] " or "- [x] " + regex: /^(\s*)([-*])\s+\[([ x])\]\s+(.*)$/, + nextMarker: (m) => `${m[1]}${m[2]} [ ] ` + }, + { + // Unordered List: "- " or "* " + regex: /^(\s*)([-*])\s+(.*)$/, + nextMarker: (m) => `${m[1]}${m[2]} ` + }, + { + // Ordered List: "1. " + regex: /^(\s*)(\d+)\.\s+(.*)$/, + nextMarker: (m) => `${m[1]}${parseInt(m[2], 10) + 1}. ` + } +]; + +export const handleEnterForListContinuation = (view: EditorView): boolean => { + const { state } = view; + const selection = state.selection.main; + + if (!selection.empty) return false; + + const line = state.doc.lineAt(selection.from); + const lineText = line.text; + const cursorInLine = selection.from - line.from; + + for (const pattern of LIST_PATTERNS) { + const match = lineText.match(pattern.regex); + if (!match) continue; + + // content matches the last capture group in all patterns above + const content = match[match.length - 1]; + + if (!content.trim()) { + // Empty list item -> Exit list (delete the line content) + view.dispatch({ + changes: { from: line.from, to: line.to, insert: '' }, + selection: { anchor: line.from } + }); + return true; + } + + // Split line at cursor and insert new list item + const textBeforeCursor = lineText.substring(0, cursorInLine); + const textAfterCursor = lineText.substring(cursorInLine); + const newItemMarker = pattern.nextMarker(match); + + view.dispatch({ + changes: { + from: line.from, + to: line.to, + insert: `${textBeforeCursor}\n${newItemMarker}${textAfterCursor}` + }, + selection: { + // Cursor placed after the new marker + anchor: line.from + textBeforeCursor.length + 1 + newItemMarker.length + } + }); + return true; + } + + return false; +}; + +export const listContinuationKeymap: KeyBinding[] = [ + { + key: "Enter", + run: handleEnterForListContinuation + } +]; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/transformToMarkdown.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/transformToMarkdown.ts new file mode 100644 index 00000000000..7d0c0359bdb --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/transformToMarkdown.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { iterateTokenStream } from "../../MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils"; +import { TokenType } from "../../MultiModeExpressionEditor/ChipExpressionEditor/types"; +import { getParsedExpressionTokens, detectTokenPatterns } from "../../MultiModeExpressionEditor/ChipExpressionEditor/utils"; + +// Escapes HTML special characters to prevent XSS +const escapeHtml = (text: string): string => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +}; + +// Represents a token that should be rendered as a chip +type RenderableToken = { + start: number; + end: number; + chipTag: string; +}; + +// Creates a chip tag for any token type +const createChipTag = (type: string, content: string, documentType?: string): string => { + const escapedContent = escapeHtml(content); + const docTypeAttr = documentType ? ` data-doc-type="${escapeHtml(documentType)}"` : ''; + return `${escapedContent}`; +}; + +// Transforms an expression with tokens to markdown with chip tags +export const transformExpressionToMarkdown = ( + expression: string, + tokenStream: number[] +): string => { + if (!expression || !tokenStream || tokenStream.length === 0) { + return expression || ''; + } + + try { + const parsedTokens = getParsedExpressionTokens(tokenStream, expression); + const compounds = detectTokenPatterns(parsedTokens, expression); + const renderableTokens: RenderableToken[] = []; + + // Use the shared iterator from CodeUtils + iterateTokenStream(parsedTokens, compounds, expression, { + onCompound: (compound) => { + let chipTag: string | undefined; + if (compound.tokenType === TokenType.DOCUMENT && compound.metadata.documentType) { + chipTag = createChipTag(TokenType.DOCUMENT, compound.metadata.content, compound.metadata.documentType); + } else if (compound.tokenType === TokenType.VARIABLE) { + chipTag = createChipTag(TokenType.VARIABLE, compound.metadata.content); + } + + if (chipTag) { + renderableTokens.push({ + start: compound.start, + end: compound.end, + chipTag + }); + } + }, + onToken: (token, content) => { + const chipTag = createChipTag(token.type, content); + if (chipTag) { + renderableTokens.push({ + start: token.start, + end: token.end, + chipTag + }); + } + } + }); + + // If no tokens to render, return original expression + if (renderableTokens.length === 0) { + return expression; + } + + let transformed = expression; + for (const token of renderableTokens) { + transformed = + transformed.slice(0, token.start) + + token.chipTag + + transformed.slice(token.end); + } + return transformed; + + } catch (error) { + console.error('Error transforming expression to markdown:', error); + return expression; + } +}; + +export const hasTokens = (expression: string): boolean => { + return /\$\{[^}]+\}/.test(expression); +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx index b35ba5c4081..bc8dbf0e081 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx @@ -23,12 +23,10 @@ import { Button, CompletionItem, ErrorBanner, - FormExpressionEditor, FormExpressionEditorRef, HelperPaneHeight, RequiredFormInput, - ThemeColors, - Tooltip + ThemeColors } from '@wso2/ui-toolkit'; import { getPropertyFromFormField, sanitizeType } from './utils'; import { FormField, FormExpressionEditorProps, HelperpaneOnChangeOptions } from '../Form/types'; @@ -45,7 +43,7 @@ import ModeSwitcher from '../ModeSwitcher'; import { ExpressionField } from './ExpressionField'; import WarningPopup from '../WarningPopup'; import { InputMode } from './MultiModeExpressionEditor/ChipExpressionEditor/types'; -import { getDefaultExpressionMode, getInputModeFromTypes } from './MultiModeExpressionEditor/ChipExpressionEditor/utils'; +import { getInputModeFromTypes } from './MultiModeExpressionEditor/ChipExpressionEditor/utils'; import { ExpandedEditor } from './ExpandedEditor'; export type ContextAwareExpressionEditorProps = { @@ -344,8 +342,10 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { const key = fieldKey ?? field.key; const [focused, setFocused] = useState(false); const [inputMode, setInputMode] = useState(recordTypeField ? InputMode.GUIDED : InputMode.EXP); + const inputModeRef = useRef(inputMode); const [isExpressionEditorHovered, setIsExpressionEditorHovered] = useState(false); const [showModeSwitchWarning, setShowModeSwitchWarning] = useState(false); + const [targetInputMode, setTargetInputMode] = useState(null); const [formDiagnostics, setFormDiagnostics] = useState(field.diagnostics); const [isExpandedModalOpen, setIsExpandedModalOpen] = useState(false); @@ -354,6 +354,11 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { setFormDiagnostics(field.diagnostics); }, [field.diagnostics]); + // Keep inputModeRef in sync with inputMode state + useEffect(() => { + inputModeRef.current = inputMode; + }, [inputMode]); + // If Form directly calls ExpressionEditor without setting targetLineRange and fileName through context const { targetLineRange: contextTargetLineRange, fileName: contextFileName } = useFormContext(); @@ -375,14 +380,14 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { fetchedInitialDiagnostics: false, diagnosticsFetchedTargetLineRange: undefined }); - const fieldValue = rawExpression ? rawExpression(watch(key)) : watch(key); + const fieldValue = inputModeRef.current === InputMode.TEMPLATE && rawExpression ? rawExpression(watch(key)) : watch(key); // Initial render useEffect(() => { if (!targetLineRange) return; // Fetch initial diagnostics if (getExpressionEditorDiagnostics && fieldValue !== undefined - && inputMode === InputMode.EXP + && (inputMode === InputMode.EXP || inputMode === InputMode.TEMPLATE) && (previousDiagnosticsFetchContext.current.fetchedInitialDiagnostics === false || previousDiagnosticsFetchContext.current.diagnosticsFetchedTargetLineRange !== targetLineRange )) { @@ -406,7 +411,7 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { setInputMode(InputMode.GUIDED); return; } - + let newInputMode = getInputModeFromTypes(field.valueTypeConstraint) if (isModeSwitcherRestricted()) { setInputMode(InputMode.EXP); @@ -424,15 +429,23 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { ) ) { setInputMode(InputMode.EXP) - } - else { + } else if (newInputMode === InputMode.TEMPLATE) { + if (sanitizedExpression && rawExpression) { + const sanitized = sanitizedExpression(initialFieldValue.current as string); + if (sanitized !== initialFieldValue.current || !initialFieldValue.current || initialFieldValue.current.trim() === '') { + setInputMode(InputMode.TEMPLATE); + } else { + setInputMode(InputMode.EXP); + } + } + } else { setInputMode(newInputMode); } }, [field?.valueTypeConstraint, recordTypeField]); const handleFocus = async (controllerOnChange?: (value: string) => void) => { setFocused(true); - + // If in guided mode with recordTypeField, open ConfigureRecordPage directly if (inputMode === InputMode.GUIDED && recordTypeField && onOpenRecordConfigPage) { const currentValue = watch(key) || ''; @@ -447,7 +460,7 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { onOpenRecordConfigPage(key, currentValue, recordTypeField, onChangeCallback); return; } - + // Trigger actions on focus await onFocus?.(); handleOnFieldFocus?.(key); @@ -499,6 +512,7 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { recordTypeField, field.type === "LV_EXPRESSION", field.valueTypeConstraint, + inputModeRef.current, ); }; @@ -507,32 +521,62 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { }; const handleModeChange = (value: InputMode) => { - const currentValue = watch(key); + const raw = watch(key); + const currentValue = typeof raw === "string" ? raw.trim() : ""; + + // Warn when switching from EXP to TEXT if value doesn't have quotes if ( inputMode === InputMode.EXP && value === InputMode.TEXT && (!currentValue.trim().startsWith("\"") || !currentValue.trim().endsWith("\"")) && currentValue.trim() !== '' ) { + setTargetInputMode(value); setShowModeSwitchWarning(true); return; } + + // Warn when switching from EXP to TEMPLATE if sanitization would hide parts of the expression + if ( + inputMode === InputMode.EXP + && value === InputMode.TEMPLATE + && sanitizedExpression + && currentValue + && currentValue.trim() !== '' + ) { + setTargetInputMode(value); + if (currentValue === sanitizedExpression(currentValue)) { + setShowModeSwitchWarning(true); + } else { + setInputMode(value); + } + return; + } + + // Auto-add quotes when switching from TEXT to EXP if not present if (inputMode === InputMode.TEXT && value === InputMode.EXP) { if (currentValue && typeof currentValue === 'string' && !currentValue.startsWith('"') && !currentValue.endsWith('"')) { setValue(key, `"${currentValue}"`); } } + setInputMode(value); }; const handleModeSwitchWarningContinue = () => { - const defaultMode = getDefaultExpressionMode(field.valueTypeConstraint); - setInputMode(defaultMode); + if (targetInputMode !== null) { + setInputMode(targetInputMode); + setTargetInputMode(null); + if (targetInputMode === InputMode.TEMPLATE && inputMode === InputMode.EXP && rawExpression) { + setValue(key, rawExpression("")); + } + } setShowModeSwitchWarning(false); }; const handleModeSwitchWarningCancel = () => { + setTargetInputMode(null); setShowModeSwitchWarning(false); }; @@ -549,7 +593,7 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { // Only allow opening expanded mode for specific fields or expression mode const onOpenExpandedMode = (!props.isInExpandedMode && - (["query", "instructions", "role"].includes(field.key) || inputMode === InputMode.EXP)) + (["query", "instructions", "role"].includes(field.key) || inputMode === InputMode.EXP || inputMode === InputMode.TEMPLATE)) ? handleOpenExpandedMode : undefined; @@ -632,7 +676,8 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { fileName={effectiveFileName} targetLineRange={effectiveTargetLineRange} autoFocus={recordTypeField ? false : autoFocus} - sanitizedExpression={sanitizedExpression} + sanitizedExpression={inputMode === InputMode.TEMPLATE ? sanitizedExpression : undefined} + rawExpression={inputMode === InputMode.TEMPLATE ? rawExpression : undefined} ariaLabel={field.label} placeholder={placeholder} onChange={async (updatedValue: string, updatedCursorPosition: number) => { @@ -642,10 +687,12 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { // clear field diagnostics setFormDiagnostics([]); - const rawValue = rawExpression ? rawExpression(updatedValue) : updatedValue; + // Use ref to get current mode (not stale closure value) + const currentMode = inputModeRef.current; + const rawValue = currentMode === InputMode.TEMPLATE && rawExpression ? rawExpression(updatedValue) : updatedValue; onChange(rawValue); - if (getExpressionEditorDiagnostics && inputMode === InputMode.EXP) { + if (getExpressionEditorDiagnostics && (currentMode === InputMode.EXP || currentMode === InputMode.TEMPLATE)) { getExpressionEditorDiagnostics( (required ?? !field.optional) || rawValue !== '', rawValue, @@ -713,10 +760,12 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { // clear field diagnostics setFormDiagnostics([]); - const rawValue = rawExpression ? rawExpression(updatedValue) : updatedValue; + // Use ref to get current mode (not stale closure value) + const currentMode = inputModeRef.current; + const rawValue = currentMode === InputMode.TEMPLATE && rawExpression ? rawExpression(updatedValue) : updatedValue; onChange(rawValue); - if (getExpressionEditorDiagnostics && inputMode === InputMode.EXP) { + if (getExpressionEditorDiagnostics && (currentMode === InputMode.EXP || currentMode === InputMode.TEMPLATE)) { getExpressionEditorDiagnostics( (required ?? !field.optional) || rawValue !== '', rawValue, @@ -749,12 +798,16 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { setIsExpandedModalOpen(false) }} onSave={handleSaveExpandedMode} - mode={inputMode === InputMode.EXP ? "expression" : undefined} + mode={inputModeRef.current === InputMode.EXP ? "expression" : inputModeRef.current === InputMode.TEMPLATE ? "template" : undefined} completions={completions} fileName={effectiveFileName} targetLineRange={effectiveTargetLineRange} + sanitizedExpression={inputModeRef.current === InputMode.TEMPLATE ? sanitizedExpression : undefined} + rawExpression={inputModeRef.current === InputMode.TEMPLATE ? rawExpression : undefined} extractArgsFromFunction={handleExtractArgsFromFunction} getHelperPane={handleGetHelperPane} + error={error} + formDiagnostics={formDiagnostics} /> )} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx index aa5b6638faf..cbccd701db9 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx @@ -41,6 +41,7 @@ export interface ExpressionField { completions: CompletionItem[]; autoFocus?: boolean; sanitizedExpression?: (value: string) => string; + rawExpression?: (value: string) => string; ariaLabel?: string; placeholder?: string; onChange: (updatedValue: string, updatedCursorPosition: number) => void; @@ -122,6 +123,7 @@ export const ExpressionField: React.FC = ({ anchorRef, onToggleHelperPane, sanitizedExpression, + rawExpression, onOpenExpandedMode, isInExpandedMode }) => { @@ -156,6 +158,8 @@ export const ExpressionField: React.FC = ({ completions={completions} onChange={onChange} value={value} + sanitizedExpression={sanitizedExpression} + rawExpression={rawExpression} fileName={fileName} targetLineRange={targetLineRange} extractArgsFromFunction={extractArgsFromFunction} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CHipTest.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CHipTest.tsx deleted file mode 100644 index b261b4fa821..00000000000 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CHipTest.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useEffect, useState } from "react"; - -export const ChipTest = () => { - const [focusState, setFocusState] = useState<{ cursor: number, caretPosition: number }>({ cursor: 0, caretPosition: 2 }); - const spanRefs = [React.createRef(), React.createRef()]; - - const setCaretAtPosition = (element: HTMLSpanElement, position: number) => { - if (!element) return; - - const selection = window.getSelection(); - const range = document.createRange(); - - // Ensure position is within bounds - const textLength = element.textContent?.length || 0; - const safePosition = Math.max(0, Math.min(position, textLength)); - - try { - range.setStart(element.firstChild || element, safePosition); - range.setEnd(element.firstChild || element, safePosition); - - selection?.removeAllRanges(); - selection?.addRange(range); - } catch (error) { - console.warn('Could not set caret position:', error); - } - }; - - function getCaretCharacterOffsetWithin(element) { - let caretOffset = 0; - const selection = window.getSelection(); - - if (selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - const preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(element); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretOffset = preCaretRange.toString().length; - } - console.log('Caret Offset:', caretOffset); - return caretOffset; - } - - const restoreCaret = (el: HTMLSpanElement, index:number) => { - const offset = focusState[index].caretPosition; - setCaretAtPosition(el, offset); - } - - const saveSelection = (el: HTMLSpanElement, index: number) => { - const caretPos = getCaretCharacterOffsetWithin(el); - // setFocusState(prev => ) - } - - const handleOnFocus = (element: HTMLSpanElement, index: number) => { - restoreCaret(element, index); - // if (!element) return; - - // setFocusState(prev => ({ ...prev, cursor: index })); - // const caretPos = getCaretCharacterOffsetWithin(element); - // setFocusState(prev => ({ ...prev, caretPosition: caretPos })); - } - - // useEffect(() => { - // if (spanRefs[focusState.cursor]?.current) { - // spanRefs[focusState.cursor].current.focus(); - // setCaretAtPosition(focusState.caretPosition); - // } - // }, [focusState.cursor, focusState.caretPosition]); - - return ( -
-
- Current Cursor: {focusState.cursor} - Caret Position: {focusState.caretPosition} - -
- { - handleOnFocus(e.target,0); - }} - style={{ - width: '200px', - height: '100px', - backgroundColor: 'green' - }} - > - qawfwfawf - - { - handleOnFocus(e.target, 1); - }} - - style={{ - width: '200px', - height: '100px', - backgroundColor: 'green' - }} - > - awdawdawd - -
- - ); -} \ No newline at end of file diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts index 0a6136dc8e6..c49580ee302 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts @@ -18,65 +18,56 @@ import { StateEffect, StateField, RangeSet, Transaction, SelectionRange, Annotation } from "@codemirror/state"; import { WidgetType, Decoration, ViewPlugin, EditorView, ViewUpdate } from "@codemirror/view"; -import { ParsedToken, filterCompletionsByPrefixAndType, getParsedExpressionTokens, getWordBeforeCursor, getWordBeforeCursorPosition } from "./utils"; +import { filterCompletionsByPrefixAndType, getParsedExpressionTokens, detectTokenPatterns, ParsedToken, mapRawToSanitized } from "./utils"; import { defaultKeymap, historyKeymap } from "@codemirror/commands"; import { CompletionItem } from "@wso2/ui-toolkit"; import { ThemeColors } from "@wso2/ui-toolkit"; import { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { TokenType, TokenMetadata, CompoundTokenSequence } from "./types"; +import { + CHIP_TEXT_STYLES, + BASE_CHIP_STYLES, + BASE_ICON_STYLES, + getTokenIconClass, + getTokenTypeColor, + getChipDisplayContent +} from "./chipStyles"; export type TokenStream = number[]; +export type TokensChangePayload = { + tokens: TokenStream; + rawValue?: string; // Raw expression (e.g., `${var}`) + sanitizedValue?: string; // Sanitized expression (e.g., ${var}) +}; + export type CursorInfo = { top: number; left: number; position: SelectionRange; } -export type TokenType = 'variable' | 'property' | 'parameter'; - export const ProgrammerticSelectionChange = Annotation.define(); export const SyncDocValueWithPropValue = Annotation.define(); -export function createChip(text: string, type: TokenType, start: number, end: number, view: EditorView) { +export function createChip(text: string, type: TokenType, start: number, end: number, view: EditorView, metadata?: TokenMetadata) { class ChipWidget extends WidgetType { - constructor(readonly text: string, readonly type: TokenType, readonly start: number, readonly end: number, readonly view: EditorView) { + constructor( + readonly text: string, + readonly type: TokenType, + readonly start: number, + readonly end: number, + readonly view: EditorView, + readonly metadata?: TokenMetadata + ) { super(); } toDOM() { const span = document.createElement("span"); - span.textContent = this.type === 'parameter' && /^\$\d+$/.test(this.text) ? ' ' : this.text; - - let backgroundColor = "rgba(0, 122, 204, 0.6)"; - let color = "white"; - switch (this.type) { - case 'variable': - case 'property': - backgroundColor = "rgba(0, 122, 204, 0.6)"; - break; - case 'parameter': - backgroundColor = "#70c995"; - color = "black"; - break; - default: - backgroundColor = "rgba(0, 122, 204, 0.6)"; - } - span.style.background = backgroundColor; - span.style.color = color; - span.style.borderRadius = "4px"; - span.style.padding = "2px 6px"; - span.style.margin = "1px 0px"; - span.style.display = "inline-block"; - span.style.cursor = "pointer"; - span.style.fontSize = "12px"; - span.style.lineHeight = "12px"; - span.style.minHeight = "14px"; - span.style.minWidth = "25px"; - span.style.transition = "all 0.2s ease"; - span.style.outline = "none"; - span.style.verticalAlign = "middle"; - span.style.userSelect = "none"; + this.createChip(span); + // Add click handler to select the chip text span.addEventListener("click", (event) => { event.preventDefault(); @@ -89,6 +80,42 @@ export function createChip(text: string, type: TokenType, start: number, end: nu return span; } + + private createChip(span: HTMLSpanElement) { + let displayText = getChipDisplayContent(this.type, this.text); + if (this.type === TokenType.DOCUMENT) { + displayText = this.metadata?.content || this.text; + } + + const colors = getTokenTypeColor(this.type); + + // Apply base styles to the chip container + Object.assign(span.style, { + ...BASE_CHIP_STYLES, + background: colors.background, + border: `1px solid ${colors.border}` + }); + + // Create icon element for standard chip + const icon = document.createElement("i"); + let iconClass = getTokenIconClass(this.type, this.metadata?.documentType); + if (iconClass) { + icon.className = iconClass; + } + Object.assign(icon.style, { + ...BASE_ICON_STYLES, + color: colors.icon + }); + + // Create text span with ellipsis handling + const textSpan = document.createElement("span"); + textSpan.textContent = displayText; + Object.assign(textSpan.style, CHIP_TEXT_STYLES); + + span.appendChild(icon); + span.appendChild(textSpan); + } + ignoreEvent() { return false; } @@ -97,7 +124,7 @@ export function createChip(text: string, type: TokenType, start: number, end: nu } } return Decoration.replace({ - widget: new ChipWidget(text, type, start, end, view), + widget: new ChipWidget(text, type, start, end, view, metadata), inclusive: false, block: false }); @@ -167,35 +194,133 @@ export const completionTheme = EditorView.theme({ }, }); -export const tokensChangeEffect = StateEffect.define(); +export const tokensChangeEffect = StateEffect.define(); export const removeChipEffect = StateEffect.define(); // contains token ID -export const tokenField = StateField.define({ +export type TokenFieldState = { + tokens: ParsedToken[]; + compounds: CompoundTokenSequence[]; +}; + +export const tokenField = StateField.define({ create() { - return []; + return { tokens: [], compounds: [] }; }, - update(oldTokens, tr) { - - oldTokens = oldTokens.map(token => ({ + update(oldState, tr) { + // Map existing positions through changes + let tokens = oldState.tokens.map(token => ({ ...token, start: tr.changes.mapPos(token.start, 1), end: tr.changes.mapPos(token.end, -1) })); + let compounds = oldState.compounds.map(compound => ({ + ...compound, + start: tr.changes.mapPos(compound.start, 1), + end: tr.changes.mapPos(compound.end, -1) + })); + for (let effect of tr.effects) { if (effect.is(tokensChangeEffect)) { - const tokenObjects = getParsedExpressionTokens(effect.value, tr.newDoc.toString()); - return tokenObjects; + const payload = effect.value; + const sanitizedDoc = tr.newDoc.toString(); + + // Parse tokens using the raw value if provided, otherwise use sanitized + const valueForParsing = payload.rawValue || sanitizedDoc; + tokens = getParsedExpressionTokens(payload.tokens, valueForParsing); + + // If we have both raw and sanitized values, map positions + if (payload.rawValue && payload.sanitizedValue) { + tokens = tokens.map(token => ({ + ...token, + start: mapRawToSanitized(token.start, payload.rawValue!, payload.sanitizedValue!), + end: mapRawToSanitized(token.end, payload.rawValue!, payload.sanitizedValue!) + })); + } + + // Detect compounds once when tokens change + compounds = detectTokenPatterns(tokens, sanitizedDoc); + + return { tokens, compounds }; } if (effect.is(removeChipEffect)) { const removingTokenId = effect.value; - return oldTokens.filter(token => token.id !== removingTokenId); + tokens = tokens.filter(token => token.id !== removingTokenId); + + // Recompute compounds after token removal + const docText = tr.newDoc.toString(); + compounds = detectTokenPatterns(tokens, docText); + + return { tokens, compounds }; } } - return oldTokens; + return { tokens, compounds }; } }); +export const iterateTokenStream = ( + tokens: ParsedToken[], + compounds: CompoundTokenSequence[], + content: string, + callbacks: { + onCompound: (compound: CompoundTokenSequence) => void; + onToken: (token: ParsedToken, text: string) => void; + } +) => { + const docLength = content.length; + + const compoundsByStartIndex = new Map(); + const compoundTokenIndices = new Set(); + + for (const compound of compounds) { + // Validate compound range + if (compound.start < 0 || compound.end > docLength || compound.start >= compound.end) { + continue; + } + + // Group compounds by their starting token index + const existing = compoundsByStartIndex.get(compound.startIndex) || []; + existing.push(compound); + compoundsByStartIndex.set(compound.startIndex, existing); + + // Mark all indices within this compound as consumed + for (let i = compound.startIndex; i <= compound.endIndex; i++) { + compoundTokenIndices.add(i); + } + } + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + // Check if any compounds begin at this token index + const startingCompounds = compoundsByStartIndex.get(i); + if (startingCompounds) { + // Trigger callback for each compound starting here + for (const compound of startingCompounds) { + callbacks.onCompound(compound); + } + } + + // Check if the individual token is consumed by a compound + if (compoundTokenIndices.has(i)) { + continue; + } + + // Skip START_EVENT and END_EVENT tokens + if (token.type === TokenType.START_EVENT || token.type === TokenType.END_EVENT) { + continue; + } + + // Validate token range + if (token.start < 0 || token.end > docLength || token.start >= token.end) { + continue; + } + + const text = content.slice(token.start, token.end); + callbacks.onToken(token, text); + } +}; + export const chipPlugin = ViewPlugin.fromClass( class { decorations: RangeSet; @@ -212,13 +337,35 @@ export const chipPlugin = ViewPlugin.fromClass( } } buildDecorations(view: EditorView) { - const widgets = []; - const tokens = view.state.field(tokenField); - - for (const token of tokens) { - const text = view.state.doc.sliceString(token.start, token.end); - widgets.push(createChip(text, token.type, token.start, token.end, view).range(token.start, token.end)); - } + const widgets: any[] = []; // Type as any[] to allow pushing Range + const { tokens, compounds } = view.state.field(tokenField); + const docContent = view.state.doc.toString(); + + iterateTokenStream(tokens, compounds, docContent, { + onCompound: (compound) => { + widgets.push( + createChip( + compound.displayText, + compound.tokenType, + compound.start, + compound.end, + view, + compound.metadata + ).range(compound.start, compound.end) + ); + }, + onToken: (token, text) => { + widgets.push( + createChip( + text, + token.type, + token.start, + token.end, + view + ).range(token.start, token.end) + ); + } + }); return Decoration.set(widgets, true); } @@ -231,14 +378,35 @@ export const chipPlugin = ViewPlugin.fromClass( export const expressionEditorKeymap = [ { key: "Backspace", - run: (view) => { + run: (view: EditorView) => { const state = view.state; - const tokens = state.field(tokenField, false); - if (!tokens) return false; + const tokenState = state.field(tokenField, false); + if (!tokenState) return false; + const { tokens, compounds } = tokenState; const cursor = state.selection.main.head; - const affectedToken = tokens.find(token => token.start < cursor && token.end >= cursor); + // Check if cursor is within a compound token + const affectedCompound = compounds.find( + compound => compound.start < cursor && compound.end >= cursor + ); + + if (affectedCompound) { + // Delete all tokens in the compound sequence + const effects = []; + for (let i = affectedCompound.startIndex; i <= affectedCompound.endIndex; i++) { + effects.push(removeChipEffect.of(tokens[i].id)); + } + + view.dispatch({ + effects, + changes: { from: affectedCompound.start, to: affectedCompound.end, insert: '' } + }); + return true; + } + + // Check for individual tokens + const affectedToken = tokens.find((token: ParsedToken) => token.start < cursor && token.end >= cursor); if (affectedToken) { view.dispatch({ @@ -339,6 +507,12 @@ export const buildOnFocusOutListner = (onTrigger: () => void) => { export const buildNeedTokenRefetchListner = (onTrigger: () => void) => { const needTokenRefetchListner = EditorView.updateListener.of((update) => { const userEvent = update.transactions[0]?.annotation(Transaction.userEvent); + + if (update.docChanged && (userEvent === "undo" || userEvent === "redo")) { + onTrigger(); + return; + } + if (update.docChanged && ( userEvent === "input.type" || userEvent === "input.paste" || @@ -436,7 +610,7 @@ export const buildHelperPaneKeymap = (getIsHelperPaneOpen: () => boolean, onClos return [ { key: "Escape", - run: (_view) => { + run: (_view: EditorView) => { if (!getIsHelperPaneOpen()) return false; onClose(); return true; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/chipStyles.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/chipStyles.ts new file mode 100644 index 00000000000..35d76870faf --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/chipStyles.ts @@ -0,0 +1,127 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TokenType, DocumentType } from "./types"; +import styled from "@emotion/styled"; + +export const BASE_CHIP_STYLES = { + borderRadius: "4px", + display: "inline-flex", + alignItems: "center", + fontSize: "12px", + minHeight: "20px", + outline: "none", + verticalAlign: "middle", + userSelect: "none", + margin: "2px 0px", + padding: "2px 8px", + cursor: "pointer", + minWidth: "25px", + transition: "all 0.2s ease", + gap: "4px", +} as const; + +export const BASE_ICON_STYLES = { + display: "flex", + fontSize: "16px" +} as const; + +export const CHIP_TEXT_STYLES = { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} as const; + +export const TOKEN_TYPE_COLORS: Partial> = { + [TokenType.VARIABLE]: { + background: "rgba(59, 130, 246, 0.15)", + border: "rgba(0, 122, 204, 0.4)", + icon: "rgba(59, 130, 246, 0.9)" + }, + [TokenType.PROPERTY]: { + background: "rgba(59, 130, 246, 0.15)", + border: "rgba(59, 130, 246, 0.4)", + icon: "rgba(59, 130, 246, 0.9)" + }, + [TokenType.PARAMETER]: { + background: "rgba(0, 204, 109, 0.15)", + border: "rgba(0, 204, 109, 0.4)", + icon: "rgba(0, 134, 71, 0.9)" + }, + [TokenType.DOCUMENT]: { + background: "rgba(59, 130, 246, 0.15)", + border: "rgba(59, 130, 246, 0.4)", + icon: "rgba(59, 130, 246, 0.9)" + } +}; + +export const DEFAULT_CHIP_COLOR = { + background: "rgba(59, 130, 246, 0.15)", + border: "rgba(59, 130, 246, 0.4)", + icon: "rgba(59, 130, 246, 0.9)" +}; + +export const DOCUMENT_ICON_CLASS_MAP: Record = { + 'ImageDocument': 'fw-bi-image', + 'FileDocument': 'fw-bi-doc', + 'AudioDocument': 'fw-bi-audio' +}; + +export const STANDARD_ICON_CLASS_MAP: Partial> = { + [TokenType.VARIABLE]: 'fw-bi-variable', + [TokenType.FUNCTION]: 'fw-bi-function', + [TokenType.PARAMETER]: 'fw-bi-variable', + [TokenType.PROPERTY]: 'fw-bi-variable' +}; + +export const getTokenIconClass = (tokenType: TokenType, subType?: string): string => { + if (tokenType === TokenType.DOCUMENT && subType) { + return DOCUMENT_ICON_CLASS_MAP[subType] || ''; + } + return STANDARD_ICON_CLASS_MAP[tokenType] || ''; +}; + +export const getTokenTypeColor = (tokenType: TokenType): { background: string; border: string; icon: string } => { + return TOKEN_TYPE_COLORS[tokenType] || DEFAULT_CHIP_COLOR; +}; + +export const shouldRenderAsEmptySpace = (tokenType: TokenType, content: string): boolean => { + return tokenType === TokenType.PARAMETER && /^\$\d+$/.test(content); +}; + +export const getChipDisplayContent = (tokenType: TokenType, content: string): string => { + return shouldRenderAsEmptySpace(tokenType, content) ? ' ' : content; +}; + +export const BaseChip = styled('span')(BASE_CHIP_STYLES); + +export const StandardChip = styled(BaseChip)<{ chipType: TokenType }>((props) => ({ + background: getTokenTypeColor(props.chipType).background, + border: `1px solid ${getTokenTypeColor(props.chipType).border}`, + padding: BASE_CHIP_STYLES.padding, + minWidth: BASE_CHIP_STYLES.minWidth, + transition: BASE_CHIP_STYLES.transition, + display: BASE_CHIP_STYLES.display, +})); + +export const BaseIcon = styled("i")(BASE_ICON_STYLES); +export const StandardIcon = styled(BaseIcon)<{ chipType: TokenType }>((props) => ({ + color: getTokenTypeColor(props.chipType).icon +})); + +export const ChipText = styled.span(CHIP_TEXT_STYLES); diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/AutoExpandingEditableDiv.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/AutoExpandingEditableDiv.tsx deleted file mode 100644 index 16d16a10a8e..00000000000 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/AutoExpandingEditableDiv.tsx +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { useState, useEffect, useCallback, useRef } from "react" -import { ChipEditorField } from "../styles" -import { CompletionItem, HelperPaneHeight } from "@wso2/ui-toolkit"; -import { ContextMenuContainer, Completions, FloatingButtonContainer, COMPLETIONS_WIDTH } from "../styles"; -import { CompletionsItem } from "./CompletionsItem"; -import { FloatingToggleButton } from "./FloatingToggleButton"; -import { CloseHelperIcon, OpenHelperIcon, ExpandIcon } from "./FloatingButtonIcons"; -import { DATA_CHIP_ATTRIBUTE, DATA_ELEMENT_ID_ATTRIBUTE, ARIA_PRESSED_ATTRIBUTE, CHIP_MENU_VALUE, CHIP_TRUE_VALUE, EXPANDED_EDITOR_HEIGHT } from '../constants'; -import { getCompletionsMenuPosition, isBetween } from "../utils"; -import styled from "@emotion/styled"; -import { HelperpaneOnChangeOptions } from "../../../../Form/types"; - -const ChipEditorFieldContainer = styled.div` - width: 100%; - position: relative; - - #floating-button-container { - opacity: 0; - transition: opacity 0.2s ease-in-out; - } - - #chip-expression-expand { - opacity: 0; - transition: opacity 0.2s ease-in-out; - } - - &:hover #floating-button-container, - &:hover #chip-expression-expand { - opacity: 1; - } -`; - -export type AutoExpandingEditableDivProps = { - value: string; - fieldContainerRef?: React.RefObject; - children?: React.ReactNode; - onKeyUp?: (e: React.KeyboardEvent) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - onInput?: (e: React.FormEvent) => void; - style?: React.CSSProperties; - onFocusChange?: (isFocused: boolean) => void; - isCompletionsOpen?: boolean; - completions?: CompletionItem[]; - selectedCompletionItem?: number; - menuPosition2?: { top: number; left: number }; - onCompletionSelect?: (item: CompletionItem) => void; - onCompletionHover?: (index: number) => void; - onCloseCompletions?: () => void; - getHelperPane?: ( - value: string, - onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, - helperPaneHeight: HelperPaneHeight - ) => React.ReactNode - isHelperPaneOpen?: boolean; - onHelperPaneClose?: () => void; - onToggleHelperPane?: () => void; - handleHelperPaneValueChange?: (value: string, options?: HelperpaneOnChangeOptions) => void; - isInExpandedMode?: boolean; - onOpenExpandedMode?: () => void; - helperButtonRef?: React.RefObject; - expressionHeight?: string | number; -} - -export const AutoExpandingEditableDiv = (props: AutoExpandingEditableDivProps) => { - const { - children, - onKeyUp, - onKeyDown, - onInput, - fieldContainerRef, - style - } = props; - - const [isAnyElementFocused, setIsAnyElementFocused] = useState(false); - - const menuRef = useRef(null); - const lastFocusStateRef = useRef<{ focused: boolean; isEditable: boolean }>({ focused: false, isEditable: false }); - - const renderCompletionsMenu = () => { - if (!props.isCompletionsOpen || !props.completions || !fieldContainerRef?.current) return null; - - const menuPosition = getCompletionsMenuPosition(fieldContainerRef); - - - const menuWidth = COMPLETIONS_WIDTH; - const viewportWidth = document.documentElement.clientWidth; - const adjustedLeft = Math.max(0, Math.min(menuPosition.left, viewportWidth - menuWidth - 10)); - - return ( - e.preventDefault()} - > - - {props.completions.map((item, index) => ( - props.onCompletionSelect?.(item)} - onMouseEnter={() => props.onCompletionHover?.(index)} - /> - ))} - - - ); - }; - - const renderHelperPane = () => { - if (!props.getHelperPane || !props.isHelperPaneOpen || !fieldContainerRef?.current) return null; - - const positionRef = props.isInExpandedMode && props.helperButtonRef ? props.helperButtonRef : fieldContainerRef; - if (!positionRef?.current) return null; - - const menuPosition = getCompletionsMenuPosition(positionRef as React.RefObject); - const menuWidth = COMPLETIONS_WIDTH; - const viewportWidth = document.documentElement.clientWidth; - const adjustedLeft = Math.max(0, Math.min(menuPosition.left, viewportWidth - menuWidth - 10)); - return ( - { - //HACK: Replace this with a proper solution to handle helperpane clicks. - //TODO: Need comprehensive focus management solution - const target = e.target as HTMLElement; - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target.tagName.toLowerCase() === 'vscode-text-field' || - target.tagName.toLowerCase() === 'input' || - target.closest('vscode-text-field') - ) { - return; - } - e.preventDefault(); - }} - > - {props.getHelperPane( - props.value, - props.handleHelperPaneValueChange ? props.handleHelperPaneValueChange : () => { }, - "3/4" - )} - - ); - }; - - const checkFocusState = useCallback(() => { - const container = fieldContainerRef?.current; - if (!container) { - setIsAnyElementFocused(false); - return; - } - - const activeElement = document.activeElement as HTMLElement; - - const isWithinContainer = container.contains(activeElement); - - const isEditableOrChip = - activeElement?.hasAttribute('contenteditable') || - activeElement?.getAttribute(DATA_CHIP_ATTRIBUTE) === CHIP_TRUE_VALUE || - activeElement?.hasAttribute(DATA_ELEMENT_ID_ATTRIBUTE) || - activeElement?.getAttribute(ARIA_PRESSED_ATTRIBUTE) !== null; - - const isEditableSpan = activeElement?.hasAttribute('contenteditable'); - - const newFocusState = isWithinContainer && isEditableOrChip; - - const lastState = lastFocusStateRef.current; - if (lastState.focused !== newFocusState || lastState.isEditable !== isEditableSpan) { - setIsAnyElementFocused(newFocusState); - lastFocusStateRef.current = { focused: newFocusState, isEditable: isEditableSpan }; - - if (props.onFocusChange) { - props.onFocusChange(newFocusState); - } - } - }, [fieldContainerRef]); - - const debounce = (func: Function, delay: number) => { - let timer: ReturnType; - return (...args: any[]) => { - clearTimeout(timer); - timer = setTimeout(() => func(...args), delay); - }; - }; - - const debouncedCheckFocusState = debounce(checkFocusState, 100); - - useEffect(() => { - const handleFocusChange = () => { - debouncedCheckFocusState(); - }; - document.addEventListener('focusin', handleFocusChange); - document.addEventListener('focusout', handleFocusChange); - checkFocusState(); - - return () => { - document.removeEventListener('focusin', handleFocusChange); - document.removeEventListener('focusout', handleFocusChange); - }; - }, [debouncedCheckFocusState]); - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setTimeout(() => { - props.onCloseCompletions?.(); - props.onHelperPaneClose?.(); - }, 100); - } - }; - - if (props.isCompletionsOpen || props.isHelperPaneOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [props.isCompletionsOpen, props.isHelperPaneOpen, props.onCloseCompletions, props.onHelperPaneClose]); - - const handleEditorClicked = (e: React.MouseEvent) => { - if (e.target instanceof HTMLSpanElement) return; - const spans = (e.target as HTMLElement).querySelectorAll('span[contenteditable="true"]'); - if (spans.length <= 0) return; - - let closestSpan: HTMLSpanElement | null = null; - let smallestDistance = Number.MAX_VALUE; - let matchNotFound = true; - - for (let i = 0; i < spans.length; i++) { - const span = spans[i] as HTMLSpanElement; - const spanRect = span.getBoundingClientRect(); - if (!isBetween(spanRect.top, spanRect.bottom, e.clientY)) continue; - if (spanRect.right < e.clientX) { - const distance = e.clientX - spanRect.right; - if (distance < smallestDistance) { - smallestDistance = distance; - closestSpan = span; - matchNotFound = false; - } - } else if (spanRect.left <= e.clientX && e.clientX <= spanRect.right) { - closestSpan = span; - matchNotFound = false; - break; - } - } - if (matchNotFound) { - (spans[spans.length - 1] as HTMLSpanElement).focus(); - } - if (closestSpan) { - closestSpan.focus(); - } - } - - // Determine height based on expressionHeight prop, or fall back to default behavior - const getHeightValue = () => { - if (props.expressionHeight !== undefined) { - return typeof props.expressionHeight === 'number' - ? `${props.expressionHeight}px` - : props.expressionHeight; - } - return props.isInExpandedMode ? `${EXPANDED_EDITOR_HEIGHT}px` : '100px'; - }; - - const heightValue = getHeightValue(); - - return ( - - -
- {children} -
-
- {renderCompletionsMenu()} - {renderHelperPane()} - {props.onOpenExpandedMode && !props.isInExpandedMode && ( -
- - - -
- )} - { - !props.isInExpandedMode && ( - - props.onToggleHelperPane?.()} title={props.isHelperPaneOpen ? "Close Helper" : "Open Helper"}> - {props.isHelperPaneOpen ? : } - - - ) - } -
- ) -} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipComponent.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipComponent.tsx deleted file mode 100644 index e8be29d1f88..00000000000 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipComponent.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { useRef } from "react"; -import { Chip } from "../styles"; -import { CHIP_TRUE_VALUE } from '../constants'; -import { ThemeColors } from "@wso2/ui-toolkit"; - -export type ChipProps = { - type: 'variable' | 'property' | 'parameter'; - text: string; - onClick?: (element: HTMLElement) => void; - onBlur?: () => void; - onFocus?: (element: HTMLElement) => void; - dataElementId?: string; // Add dataElementId prop -} - -export const ChipComponent = (props: ChipProps) => { - const { type, text, onClick, onBlur, onFocus, dataElementId } = props; // Destructure dataElementId - const chipRef = useRef(null); - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (onClick && chipRef.current) { - onClick(chipRef.current); - } - }; - - const handleMouseDown = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - - const handleFocus = () => { - if (onFocus && chipRef.current) { - onFocus(chipRef.current); - } - }; - - if (type === 'variable') { - return {text}; - } else if (type === 'property') { - return {text}; - } else { - return ( - - {/^\$\d+$/.test(text) ? ' ' : text} - - ); - - } -} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx index c86702d3d6f..58791969f40 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx @@ -38,6 +38,7 @@ import { ProgrammerticSelectionChange, SyncDocValueWithPropValue } from "../CodeUtils"; +import { mapSanitizedToRaw } from "../utils"; import { history } from "@codemirror/commands"; import { autocompletion } from "@codemirror/autocomplete"; import { FloatingButtonContainer, FloatingToggleButton, ChipEditorContainer } from "../styles"; @@ -48,6 +49,7 @@ import { LineRange } from "@wso2/ballerina-core"; import FXButton from "./FxButton"; import { HelperPaneToggleButton } from "./HelperPaneToggleButton"; import { HelperPane } from "./HelperPane"; +import { listContinuationKeymap } from "../../../ExpandedEditor/utils/templateUtils"; type HelperPaneState = { isOpen: boolean; @@ -78,6 +80,17 @@ export type ChipExpressionEditorComponentProps = { onRemove?: () => void; isInExpandedMode?: boolean; sx?: React.CSSProperties; + sanitizedExpression?: (value: string) => string; + rawExpression?: (value: string) => string; + showHelperPaneToggle?: boolean; + onHelperPaneStateChange?: (state: { + isOpen: boolean; + ref: React.RefObject; + toggle: () => void + }) => void; + onEditorViewReady?: (view: EditorView) => void; + toolbarRef?: React.RefObject; + enableListContinuation?: boolean; } export const ChipExpressionEditorComponent = (props: ChipExpressionEditorComponentProps) => { @@ -90,6 +103,7 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const [isTokenUpdateScheduled, setIsTokenUpdateScheduled] = useState(true); const completionsRef = useRef(props.completions); const helperPaneToggleButtonRef = useRef(null); + const savedSelectionRef = useRef<{ from: number; to: number } | null>(null); const { expressionEditor } = useFormContext(); const expressionEditorRpcManager = expressionEditor?.rpcManager; @@ -106,6 +120,7 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const isRangeSelection = cursor.position.to !== cursor.position.from; if (newValue === '' || isTrigger || isRangeSelection) { + savedSelectionRef.current = { from: cursor.position.from, to: cursor.position.to }; setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); } else { setHelperPaneState({ isOpen: false, top: 0, left: 0 }); @@ -113,10 +128,12 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone }); const handleFocusListner = buildOnFocusListner((cursor: CursorInfo) => { + savedSelectionRef.current = { from: cursor.position.from, to: cursor.position.to }; setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); }); const handleSelectionChange = buildOnSelectionChange((cursor: CursorInfo) => { + savedSelectionRef.current = { from: cursor.position.from, to: cursor.position.to }; setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); }); @@ -134,7 +151,10 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const newValue = value if (!viewRef.current) return; const view = viewRef.current; - const { from, to } = options?.replaceFullText ? { from: 0, to: view.state.doc.length } : view.state.selection.main; + + // Use saved selection if available, otherwise fall back to current selection + const currentSelection = savedSelectionRef.current || view.state.selection.main; + const { from, to } = options?.replaceFullText ? { from: 0, to: view.state.doc.length } : currentSelection; let finalValue = newValue; let cursorPosition = from + newValue.length; @@ -143,17 +163,35 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone // current API response sends an incorrect response // if API sends $1,$2.. for the arguments in the template // then we can directly handled it without explicitly calling the API - // and extracting args - if (newValue.endsWith('()')) { + // and extracting args + if (newValue.endsWith('()') || newValue.endsWith(')}')) { if (props.extractArgsFromFunction) { try { - const cursorPositionForExtraction = from + newValue.length - 1; - const fnSignature = await props.extractArgsFromFunction(newValue, cursorPositionForExtraction); + // Extract the function definition from string templates like "${func()}" + let functionDef = newValue; + let prefix = ''; + let suffix = ''; + + // Check if it's within a string template + const stringTemplateMatch = newValue.match(/^(.*\$\{)([^}]+)(\}.*)$/); + if (stringTemplateMatch) { + prefix = stringTemplateMatch[1]; + functionDef = stringTemplateMatch[2]; + suffix = stringTemplateMatch[3]; + } + + let cursorPositionForExtraction = from + prefix.length + functionDef.length - 1; + if (functionDef.endsWith(')}')) { + cursorPositionForExtraction -= 1; + } + + const fnSignature = await props.extractArgsFromFunction(functionDef, cursorPositionForExtraction); if (fnSignature && fnSignature.args && fnSignature.args.length > 0) { const placeholderArgs = fnSignature.args.map((arg, index) => `$${index + 1}`); - finalValue = newValue.slice(0, -2) + '(' + placeholderArgs.join(', ') + ')'; - cursorPosition = from + finalValue.length - 1; + const updatedFunctionDef = functionDef.slice(0, -2) + '(' + placeholderArgs.join(', ') + ')'; + finalValue = prefix + updatedFunctionDef + suffix; + cursorPosition = from + prefix.length + updatedFunctionDef.length - 1; } } catch (error) { console.warn('Failed to extract function arguments:', error); @@ -169,6 +207,9 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone setIsTokenUpdateScheduled(true); } setHelperPaneState(prev => ({ ...prev, isOpen: !options.closeHelperPane })); + + // Clear saved selection after use + savedSelectionRef.current = null; } const handleHelperPaneManualToggle = () => { @@ -176,6 +217,13 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone !helperPaneToggleButtonRef?.current || !editorRef?.current ) return; + + // Save current cursor position before toggling + if (viewRef.current) { + const selection = viewRef.current.state.selection.main; + savedSelectionRef.current = { from: selection.from, to: selection.to }; + } + const buttonRect = helperPaneToggleButtonRef.current.getBoundingClientRect(); const editorRect = editorRef.current?.getBoundingClientRect(); let top = buttonRect.bottom - editorRect.top; @@ -199,13 +247,28 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone })); } + // Expose helper pane state to parent component + useEffect(() => { + if (props.onHelperPaneStateChange) { + props.onHelperPaneStateChange({ + isOpen: helperPaneState.isOpen, + ref: helperPaneToggleButtonRef, + toggle: handleHelperPaneManualToggle + }); + } + }, [helperPaneState.isOpen]); + useEffect(() => { if (!editorRef.current) return; const startState = EditorState.create({ doc: props.value ?? "", extensions: [ history(), - keymap.of([...helperPaneKeymap, ...expressionEditorKeymap]), + keymap.of([ + ...helperPaneKeymap, + ...(props.enableListContinuation ? listContinuationKeymap : []), + ...expressionEditorKeymap + ]), autocompletion({ override: [completionSource], activateOnTyping: true, @@ -236,7 +299,10 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone }, ".cm-scroller": { overflow: "auto" } })] - : []) + : [EditorView.theme({ + "&": { maxHeight: "150px" }, + ".cm-scroller": { overflow: "auto" } + })]) ] }); const view = new EditorView({ @@ -244,6 +310,12 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone parent: editorRef.current }); viewRef.current = view; + + // Notify parent component that the editor view is ready + if (props.onEditorViewReady) { + props.onEditorViewReady(view); + } + return () => { view.destroy(); }; @@ -252,20 +324,26 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone useEffect(() => { if (props.value == null || !viewRef.current) return; const updateEditorState = async () => { + const sanitizedValue = props.sanitizedExpression ? props.sanitizedExpression(props.value) : props.value; const currentDoc = viewRef.current!.state.doc.toString(); - const isExternalUpdate = props.value !== currentDoc; + const isExternalUpdate = sanitizedValue !== currentDoc; if (!isTokenUpdateScheduled && !isExternalUpdate) return; + const startLine = props.targetLineRange?.startLine; const tokenStream = await expressionEditorRpcManager?.getExpressionTokens( props.value, props.fileName, - props.targetLineRange?.startLine + startLine !== undefined ? startLine : undefined ); setIsTokenUpdateScheduled(false); - const effects = tokenStream ? [tokensChangeEffect.of(tokenStream)] : []; + const effects = tokenStream ? [tokensChangeEffect.of({ + tokens: tokenStream, + rawValue: props.value, + sanitizedValue: sanitizedValue + })] : []; const changes = isExternalUpdate - ? { from: 0, to: viewRef.current!.state.doc.length, insert: props.value } + ? { from: 0, to: viewRef.current!.state.doc.length, insert: sanitizedValue } : undefined; const annotations = isExternalUpdate ? [SyncDocValueWithPropValue.of(true)] : []; @@ -286,6 +364,11 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone completionsRef.current = props.completions; }, [props.completions]); + // Trigger token update when sanitization mode changes + useEffect(() => { + setIsTokenUpdateScheduled(true); + }, [Boolean(props.sanitizedExpression), Boolean(props.rawExpression)]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (!helperPaneState.isOpen) return; @@ -294,8 +377,9 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const isClickInsideEditor = editorRef.current?.contains(target); const isClickInsideHelperPane = helperPaneRef.current?.contains(target); const isClickOnToggleButton = helperPaneToggleButtonRef.current?.contains(target); + const isClickInsideToolbar = props.toolbarRef?.current?.contains(target); - if (!isClickInsideEditor && !isClickInsideHelperPane && !isClickOnToggleButton) { + if (!isClickInsideEditor && !isClickInsideHelperPane && !isClickOnToggleButton && !isClickInsideToolbar) { setHelperPaneState(prev => ({ ...prev, isOpen: false })); viewRef.current?.dispatch({ selection: { anchor: 0 }, @@ -304,17 +388,31 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone viewRef.current?.dom.blur(); } }; + + const handleEscapeKey = (event: KeyboardEvent) => { + if (!helperPaneState.isOpen) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + } + }; + if (helperPaneState.isOpen) { document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscapeKey); } return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscapeKey); }; - }, [helperPaneState.isOpen]); + }, [helperPaneState.isOpen, props.toolbarRef]); + + const showToggle = props.showHelperPaneToggle !== false && props.isExpandedVersion; return ( <> - {props.isExpandedVersion && ( + {showToggle && ( {helperPaneState.isOpen && void; + sx?: React.CSSProperties; + disabled?: boolean; } -const OutlineButton = styled.button<{ isOpen: boolean }>` +const OutlineButton = styled.button<{ isOpen: boolean}>` padding: 6px 12px; border-radius: 3px; border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; - background-color: ${props => props.isOpen - ? ThemeColors.SURFACE + background-color: ${props => props.isOpen + ? ThemeColors.SURFACE : ThemeColors.SURFACE_BRIGHT}; color: ${ThemeColors.ON_SURFACE}; display: inline-flex; @@ -63,6 +65,11 @@ const OutlineButton = styled.button<{ isOpen: boolean }>` flex-shrink: 0; pointer-events: none; } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } `; const ButtonText = styled.span` @@ -73,9 +80,11 @@ const ButtonText = styled.span` export const HelperPaneToggleButton = React.forwardRef(({ isOpen, - onClick + onClick, + sx, + disabled }, ref) => { - + return ( {isOpen ? : } Helper Panel diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/TextElement.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/TextElement.tsx deleted file mode 100644 index e65e2fe42d5..00000000000 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/TextElement.tsx +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { useEffect, useLayoutEffect, useRef } from "react"; -import { getCaretOffsetWithin, getAbsoluteCaretPosition, setCaretPosition, handleKeyDownInTextElement, getAbsoluteCaretPositionFromModel, hasTextSelection, getSelectionOffsets, setSelectionRange } from "../utils"; -import { ExpressionModel } from "../types"; -import { InvisibleSpan } from "../styles"; -import { FOCUS_MARKER } from "../constants"; - -export const TextElement = (props: { - element: ExpressionModel; - expressionModel: ExpressionModel[]; - sx?: React.CSSProperties; - index: number; - onTextFocus?: (e: React.FocusEvent) => void; - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void; -}) => { - const { onExpressionChange } = props; - const spanRef = useRef(null); - const pendingCaretOffsetRef = useRef(null); - const lastValueRef = useRef(props.element.value); - const isProgrammaticFocusRef = useRef(false); - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Only call handleKeyDownInTextElement when there's no text selection (caret is at a single position) - // If there's a selection, let the browser handle the default behavior - // because we only have to handle the case where the cursor is in the starting position of - // a text element and user is trying to delete or move left or right - // if user has selected a range then we do not have to care about chip deletions - // (Chips cannot be selected) - if (spanRef.current && hasTextSelection(spanRef.current)) { - return; - } - handleKeyDownInTextElement(e, props.expressionModel, props.index, onExpressionChange, spanRef.current); - }; - - // Restore caret position after a value update if we captured a pending position during input - useLayoutEffect(() => { - const host = spanRef.current; - const pending = pendingCaretOffsetRef.current; - // Only restore caret if we have a pending position and the element is the active element - if (host && pending !== null && document.activeElement === host) { - setCaretPosition(host, pending); - pendingCaretOffsetRef.current = null; - } - }, [props.element.value]); - - const updateFocusOffset = (host: HTMLSpanElement) => { - if (!onExpressionChange) return; - - // Get selection offsets (handles both caret position and selection range) - const { start, end } = getSelectionOffsets(host); - - const updatedModel = props.expressionModel.map((el, i) => - i === props.index - ? { ...el, isFocused: true, focusOffsetStart: start, focusOffsetEnd: end } - : { ...el, isFocused: false, focusOffsetStart: undefined, focusOffsetEnd: undefined } - ); - const newCursorPosition = getAbsoluteCaretPosition(updatedModel); - onExpressionChange(updatedModel, newCursorPosition, FOCUS_MARKER); - }; - - const handleMouseUp = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const host = spanRef.current; - if (!host) return; - // Sync caret after mouse placement - updateFocusOffset(host); - }; - - const handleInput = (e: React.FormEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!onExpressionChange) return; - if (!props.expressionModel) return; - - const host = spanRef.current; - - const rawNewValue = e.currentTarget.textContent || ''; - const oldValue = lastValueRef.current; - - const cursorDelta = rawNewValue.length - oldValue.length; - - const currentFocusOffset = props.element.focusOffsetStart ?? oldValue.length; - - let pendingOffset: number | null = null; - if (host) { - pendingOffset = getCaretOffsetWithin(host); - pendingCaretOffsetRef.current = pendingOffset; - } - - let newValue = rawNewValue; - - const updatedExpressionModel = [...props.expressionModel]; - let didPrependSpace = false; - if (props.index > 0) { - const previousModelElement = props.expressionModel[props.index - 1]; - if (previousModelElement.isToken && previousModelElement.length > 0 && !newValue.startsWith(" ")) { - newValue = " " + newValue; - didPrependSpace = true; - } - } - - // If we programmatically added a leading space, the caret (measured before update) - // must be shifted right by one to remain at the user's intended position. - const newFocusOffset = pendingOffset !== null - ? pendingOffset + (didPrependSpace ? 1 : 0) - : (currentFocusOffset + cursorDelta + (didPrependSpace ? 1 : 0)); - if (pendingCaretOffsetRef.current !== null) { - pendingCaretOffsetRef.current = pendingCaretOffsetRef.current + (didPrependSpace ? 1 : 0); - } - updatedExpressionModel[props.index] = { - ...props.element, - value: newValue, - length: newValue.length, - isFocused: true, - focusOffsetStart: newFocusOffset, - focusOffsetEnd: newFocusOffset - }; - const enteredText = newValue.substring( - currentFocusOffset, - newFocusOffset - ); - const newAbsoluteCursorPosition = getAbsoluteCaretPositionFromModel(updatedExpressionModel); - lastValueRef.current = newValue; - onExpressionChange(updatedExpressionModel, newAbsoluteCursorPosition, enteredText); - }; - - const handleFocus = (e: React.FocusEvent) => { - e.preventDefault(); - e.stopPropagation(); - props.onTextFocus && props.onTextFocus(e); - - if (isProgrammaticFocusRef.current) { - isProgrammaticFocusRef.current = false; - return; - } - - if (!onExpressionChange || !props.expressionModel) return; - const updatedModel = props.expressionModel.map((element, index) => { - if (index === props.index) { - return { ...element, isFocused: true, focusOffsetStart: getCaretOffsetWithin(e.currentTarget), focusOffsetEnd: getCaretOffsetWithin(e.currentTarget) }; - } else { - return { ...element, isFocused: false, focusOffsetStart: undefined, focusOffsetEnd: undefined }; - } - }) - const newCursorPosition = getAbsoluteCaretPosition(updatedModel); - onExpressionChange(updatedModel, newCursorPosition, FOCUS_MARKER); - } - - // If this element is marked as focused, focus it and set the caret/selection to focusOffset - useEffect(() => { - if (props.element.isFocused && spanRef.current) { - const host = spanRef.current; - isProgrammaticFocusRef.current = true; - host.focus(); - - const startOffset = props.element.focusOffsetStart ?? (host.textContent?.length || 0); - const endOffset = props.element.focusOffsetEnd ?? startOffset; - - if (startOffset !== endOffset) { - setSelectionRange(host, startOffset, endOffset); - } else { - setCaretPosition(host, startOffset); - } - } - }, [props.element.isFocused, props.element.focusOffsetStart, props.element.focusOffsetEnd]); - - return ( - - {props.element.value} - - ); -}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/TokenizedExpression.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/TokenizedExpression.tsx deleted file mode 100644 index 9a4d7676f99..00000000000 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/TokenizedExpression.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from "react"; -import { ReactNode } from "react"; -import { ChipComponent } from "./ChipComponent"; -import { TextElement } from "./TextElement"; -import { ExpressionModel } from "../types"; - -export const getTokenTypeFromIndex = (index: number): string => { - const tokenTypes: { [key: number]: string } = { - 0: 'variable', - 1: 'property', - 2: 'parameter' - }; - return tokenTypes[index] || 'property'; -}; - -export const getTokenChip = ( - value: string, - type: string, - absoluteOffset?: number, - onChipClick?: (element: HTMLElement, value: string, type: string, id?: string) => void, - onChipBlur?: () => void, - onChipFocus?: (element: HTMLElement, value: string, type: string, absoluteOffset?: number) => void, - chipId?: string -): ReactNode => { - const handleClick = (element: HTMLElement) => { - if (onChipClick) { - onChipClick(element, value, type, chipId); - } - }; - - const handleBlur = () => { - if (onChipBlur) { - onChipBlur(); - } - }; - - const handleFocus = (element: HTMLElement) => { - if (onChipFocus) { - onChipFocus(element, value, type, absoluteOffset); - } - }; - - return ; - -} - -export type TokenizedExpressionProps = { - expressionModel: ExpressionModel[]; - onChipClick?: (element: HTMLElement, value: string, type: string, id?: string) => void; - onChipBlur?: () => void; - onTextFocus?: (e: React.FocusEvent) => void; - onChipFocus?: (element: HTMLElement, value: string, type: string, absoluteOffset?: number) => void; - onExpressionChange?: (updatedExpression: ExpressionModel[], cursorPosition: number, lastTypedText: string) => void; -} - -export const TokenizedExpression = (props: TokenizedExpressionProps) => { - const { expressionModel, onExpressionChange } = props; - - return ( - expressionModel.length === 0 ? ( - - ) : ( - <> - {expressionModel.map((element, index) => { - if (element.isToken) { - // Use stable key to prevent React from remounting and losing focus - return ( - - {getTokenChip( - element.value, - element.type, - undefined, - props.onChipClick, - props.onChipBlur, - props.onChipFocus, - element.id - )} - - ); - } else { - return ; - } - })} - - ) - ) -} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/styles.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/styles.tsx index c4e0b1cb581..8ab785d3a34 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/styles.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/styles.tsx @@ -53,13 +53,15 @@ export const ChipEditorContainer = styled.div` export const Chip = styled.div` border-radius: 4px; - background-color: rgba(0, 122, 204, 0.7); - color: ${ThemeColors.ON_PRIMARY}; + background-color: rgba(0, 122, 204, 0.3); + color: var(--vscode-input-foreground, white); cursor: pointer; margin: 2px 0px; font-size: 12px; padding: 2px 10px; - display: inline-block; + display: inline-flex; + justify-content: center; + align-items: center; min-height: 20px; min-width: 25px; transition: all 0.2s ease; @@ -78,6 +80,53 @@ export const Chip = styled.div` } `; +export const DocumentChip = styled.div` + border-radius: 4px; + background-color: rgba(59, 130, 246, 0.15); + color: var(--vscode-input-foreground); + border: 1px solid rgba(59, 130, 246, 0.4); + cursor: pointer; + margin: 2px 0px; + font-size: 12px; + padding: 2px 10px; + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 20px; + min-width: 25px; + transition: all 0.2s ease; + outline: none; + vertical-align: middle; + user-select: none; + -webkit-user-select: none; + + &:hover { + background-color: rgba(59, 130, 246, 0.25); + } + + &:active { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.8); + } +`; + +export const DocumentChipIcon = styled.span` + font-size: 16px; + margin-right: 6px; + display: flex; + align-items: center; + color: rgba(59, 130, 246, 0.9); +`; + +export const DocumentChipText = styled.span` + font-size: 12px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + letter-spacing: 0.01em; +`; + export const COMPLETIONS_WIDTH = 300; export const ContextMenuContainer = styled.div<{ top: number; left: number }>` diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts index 422883c5b8f..3fef3473a6a 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts @@ -19,14 +19,28 @@ export enum InputMode { TEXT = "Text", EXP = "Expression", - GUIDED = "Guided" + GUIDED = "Guided", + TEMPLATE = "Template" } export const INPUT_MODE_MAP = { string: InputMode.TEXT, - //later add more when needed + "ai:Prompt": InputMode.TEMPLATE }; +export enum TokenType { + LITERAL = "literal", + VARIABLE = "variable", + FUNCTION = "function", + PARAMETER = "parameter", + START_EVENT = "start_event", + END_EVENT = "end_event", + TYPE_CAST = "type_cast", + VALUE = "value", + DOCUMENT = "document", + PROPERTY = "property" +} + export type ExpressionColumnOffset = { startColumn: number; endColumn: number; @@ -44,6 +58,14 @@ export type Token = { tokenType: 'variable' } +export type DocumentType = 'ImageDocument' | 'FileDocument' | 'AudioDocument'; + +export type TokenMetadata = { + content: string; + fullValue: string; + documentType?: DocumentType; // Present only for document tokens +}; + export type ExpressionModel = { id: string value: string, @@ -51,13 +73,33 @@ export type ExpressionModel = { startColumn: number, startLine: number, length: number, - type: 'variable' | 'function' | 'literal' | 'parameter', + type: TokenType, isFocused?: boolean focusOffsetStart?: number, focusOffsetEnd?: number + metadata?: TokenMetadata; // Present when type is 'document' or 'variable' with interpolation } export type CursorPosition = { start: number; end: number; } + +// Compound token sequence detected from multiple tokens +export type CompoundTokenSequence = { + startIndex: number; + endIndex: number; + tokenType: TokenType.VARIABLE | TokenType.DOCUMENT; + displayText: string; + metadata: TokenMetadata; + start: number; + end: number; +}; + +// Token pattern configuration for detecting compound token sequences +export type TokenPattern = { + name: TokenType.VARIABLE | TokenType.DOCUMENT; + sequence: readonly TokenType[]; + extractor: (tokens: any[], startIndex: number, endIndex: number, docText: string) => TokenMetadata | null; + priority: number; +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts index 54daddaf561..f3d55de191d 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts @@ -17,13 +17,27 @@ */ import { CompletionItem } from "@wso2/ui-toolkit"; -import { INPUT_MODE_MAP, InputMode, ExpressionModel } from "./types"; -import { BACKSPACE_MARKER, DELETE_MARKER, ARROW_RIGHT_MARKER, ARROW_LEFT_MARKER } from "./constants"; -import { TokenType } from "./CodeUtils"; +import { INPUT_MODE_MAP, InputMode, TokenType, CompoundTokenSequence, TokenMetadata, DocumentType, TokenPattern } from "./types"; const TOKEN_LINE_OFFSET_INDEX = 0; const TOKEN_START_CHAR_OFFSET_INDEX = 1; const TOKEN_LENGTH_INDEX = 2; +const TOKEN_TYPE_INDEX = 3; +const TOKEN_MODIFIERS_INDEX = 4; + +export const TOKEN_TYPE_INDEX_MAP: { [key: number]: TokenType } = { + 0: TokenType.VARIABLE, + 1: TokenType.FUNCTION, + 2: TokenType.PARAMETER, + 3: TokenType.TYPE_CAST, + 4: TokenType.VALUE, + 5: TokenType.START_EVENT, + 6: TokenType.END_EVENT +}; + +const getTokenTypeFromIndex = (index: number): TokenType => { + return TOKEN_TYPE_INDEX_MAP[index] || TokenType.VARIABLE; +}; export const getInputModeFromTypes = (valueTypeConstraint: string | string[]): InputMode => { if (!valueTypeConstraint) return; @@ -86,135 +100,26 @@ export const getTokenChunks = (tokens: number[]) => { return tokenChunks; }; -export const calculateCompletionsMenuPosition = ( - fieldContainerRef: React.RefObject, - setMenuPosition: React.Dispatch> -) => { - if (fieldContainerRef.current) { - const activeElement = document.activeElement as HTMLElement; - if (activeElement && activeElement.hasAttribute('data-element-id')) { - const rect = activeElement.getBoundingClientRect(); - const containerRect = fieldContainerRef.current.getBoundingClientRect(); - const menuWidth = 300; - let left = rect.right - containerRect.left - menuWidth; - - left = Math.max(0, left); - - setMenuPosition({ - top: rect.bottom - containerRect.top + 20, - left: left - }); - } else { - const containerRect = fieldContainerRef.current.getBoundingClientRect(); - const menuWidth = 300; - setMenuPosition({ - top: containerRect.bottom, - left: Math.max(0, containerRect.width - menuWidth) - }); - } - } else { - setMenuPosition(prev => ({ ...prev })); - } -}; - -export const getCompletionsMenuPosition = ( - fieldContainerRef: React.RefObject -) => { - const activeElement = document.activeElement as HTMLElement; - if (activeElement && activeElement.hasAttribute('data-element-id')) { - const rect = activeElement.getBoundingClientRect(); - const containerRect = fieldContainerRef.current.getBoundingClientRect(); - const menuWidth = 300; - let left = rect.right - containerRect.left - menuWidth; - - left = Math.max(0, left); - - return { - top: rect.bottom - containerRect.top + 5, - left: left - } - } else { - const containerRect = fieldContainerRef.current.getBoundingClientRect(); - const menuWidth = 300; - return ({ - top: containerRect.height, - left: Math.max(0, containerRect.width - menuWidth - 30) - }); - } -}; - -export const getTokenAtCursorPosition = ( - expressionModel: ExpressionModel[], - cursorPosition: number -) => { - let currentAbsolutePosition = 0; - - for (const item of expressionModel) { - const itemStart = currentAbsolutePosition; - const itemEnd = currentAbsolutePosition + item.length; - - if (cursorPosition >= itemStart && cursorPosition <= itemEnd) { - const offset = cursorPosition - itemStart; - return { - element: item, - offset: offset - }; - } - - currentAbsolutePosition += item.length; - } - - return null; -}; - -export const getTextValueFromExpressionModel = (expressionModel: ExpressionModel[] = []): string => { - if (!Array.isArray(expressionModel) || expressionModel.length === 0) return ""; - - return expressionModel - .map((item: ExpressionModel) => { - return item.value; - }) - .join(""); -}; - -export const expressionModelInitValue: ExpressionModel = { - id: '1', - value: '', - isToken: false, - startColumn: 0, - startLine: 0, - length: 0, - type: 'literal', - isFocused: false, - focusOffsetStart: undefined +// Parsed token with absolute positions +export type ParsedToken = { + id: number; + start: number; + end: number; + type: TokenType; } -export const createExpressionModelFromTokens = ( - value: string, - tokens: number[] -): ExpressionModel[] => { - if (!value) return []; - if (!tokens || tokens.length === 0) { - return [{ - ...expressionModelInitValue, value: value, length: value.length, - }]; - } - - const expressionModel: ExpressionModel[] = []; - const tokenChunks = getTokenChunks(tokens); - +export const getParsedExpressionTokens = (tokens: number[], value: string): ParsedToken[] => { + const chunks = getTokenChunks(tokens); let currentLine = 0; let currentChar = 0; - let previousTokenEndOffset = 0; - let idCounter = 1; + const tokenObjects: ParsedToken[] = []; - for (let i = 0; i < tokenChunks.length; i++) { - const chunk = tokenChunks[i]; + let tokenId = 0; + for (let chunk of chunks) { const deltaLine = chunk[TOKEN_LINE_OFFSET_INDEX]; const deltaStartChar = chunk[TOKEN_START_CHAR_OFFSET_INDEX]; - const tokenLength = chunk[TOKEN_LENGTH_INDEX]; - const tokenTypeIndex = chunk[3]; - const tokenModifiers = chunk[4]; + const length = chunk[TOKEN_LENGTH_INDEX]; + const type = chunk[TOKEN_TYPE_INDEX]; currentLine += deltaLine; if (deltaLine === 0) { @@ -223,1042 +128,204 @@ export const createExpressionModelFromTokens = ( currentChar = deltaStartChar; } - const tokenAbsoluteOffset = getAbsoluteColumnOffset(value, currentLine, currentChar); - - if (tokenAbsoluteOffset > previousTokenEndOffset) { - const literalValue = value.slice(previousTokenEndOffset, tokenAbsoluteOffset); - const literalStartLine = getLineFromAbsoluteOffset(value, previousTokenEndOffset); - const literalStartColumn = previousTokenEndOffset - getAbsoluteColumnOffset(value, literalStartLine, 0); - - expressionModel.push({ - id: String(idCounter++), - value: literalValue, - isToken: false, - startColumn: literalStartColumn, - startLine: literalStartLine, - length: literalValue.length, - type: 'literal' - }); - } - - const tokenValue = value.slice(tokenAbsoluteOffset, tokenAbsoluteOffset + tokenLength); - const tokenType = getTokenTypeFromIndex(tokenTypeIndex); - - expressionModel.push({ - id: String(idCounter++), - value: tokenValue, - isToken: true, - startColumn: currentChar, - startLine: currentLine, - length: tokenLength, - type: tokenType as 'variable' | 'function' | 'literal' - }); - - previousTokenEndOffset = tokenAbsoluteOffset + tokenLength; - } - - if (previousTokenEndOffset < value.length) { - const literalValue = value.slice(previousTokenEndOffset); - const literalStartLine = getLineFromAbsoluteOffset(value, previousTokenEndOffset); - const literalStartColumn = previousTokenEndOffset - getAbsoluteColumnOffset(value, literalStartLine, 0); - - expressionModel.push({ - id: String(idCounter++), - value: literalValue, - isToken: false, - startColumn: literalStartColumn, - startLine: literalStartLine, - length: literalValue.length, - type: 'literal' - }); - } - - if (expressionModel.length > 0 && expressionModel[0].isToken) { - expressionModel.unshift({ - id: '0', - value: '', - isToken: false, - startColumn: 0, - startLine: 0, - length: 0, - type: 'literal' - }); - } - - if (expressionModel.length > 0 && expressionModel[expressionModel.length - 1].isToken) { - const endLine = getLineFromAbsoluteOffset(value, value.length); - const endColumnBase = getAbsoluteColumnOffset(value, endLine, 0); - const endColumn = (typeof endColumnBase === 'number') ? (value.length - endColumnBase) : 0; - expressionModel.push({ - id: '0', - value: '', - isToken: false, - startColumn: endColumn, - startLine: endLine, - length: 0, - type: 'literal' - }); - } - - // Renumber all ids to match their final positions (1-indexed) - expressionModel.forEach((el, index) => { - el.id = String(index + 1); - }); - - return expressionModel; -}; - - - -const getTokenTypeFromIndex = (index: number): TokenType => { - const tokenTypes: { [key: number]: TokenType } = { - 0: 'variable', - 1: 'property', - 2: 'parameter', - }; - return tokenTypes[index] || 'variable'; -}; - -export const getCaretOffsetWithin = (el: HTMLElement): number => { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return 0; - const range = selection.getRangeAt(0); - if (!el.contains(range.startContainer)) return 0; - let offset = 0; - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); - let current: Node | null = walker.nextNode(); - while (current) { - if (current === range.startContainer) { - offset += range.startOffset; - break; - } else { - offset += (current.textContent || '').length; - } - current = walker.nextNode(); - } - return offset; -}; - -export const hasTextSelection = (el: HTMLElement): boolean => { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return false; - const range = selection.getRangeAt(0); - if (!el.contains(range.startContainer)) return false; - return !range.collapsed; -}; - -export const getSelectionOffsets = (el: HTMLElement): { start: number; end: number } => { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - const offset = getCaretOffsetWithin(el); - return { start: offset, end: offset }; - } - - const range = selection.getRangeAt(0); - - if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) { - const offset = getCaretOffsetWithin(el); - return { start: offset, end: offset }; - } - if (range.collapsed) { - const offset = getCaretOffsetWithin(el); - return { start: offset, end: offset }; - } - - let startOffset = 0; - let endOffset = 0; - - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); - let current: Node | null = walker.nextNode(); - - while (current) { - const textLength = (current.textContent || '').length; - - // Calculate start offset - if (current === range.startContainer) { - startOffset += range.startOffset; - } else if (current.compareDocumentPosition(range.startContainer) & Node.DOCUMENT_POSITION_FOLLOWING) { - // startContainer comes after current node - startOffset += textLength; - } - - // Calculate end offset - if (current === range.endContainer) { - endOffset += range.endOffset; - break; - } else { - endOffset += textLength; - } - - current = walker.nextNode(); - } - - return { start: startOffset, end: endOffset }; -}; - -export const getAbsoluteCaretPosition = (model: ExpressionModel[] | undefined): number => { - if (!model || model.length === 0) return 0; - const active = document.activeElement as HTMLElement | null; - if (!active) return 0; - const elementId = active.getAttribute('data-element-id'); - if (!elementId) return 0; - const idx = model.findIndex(m => m.id === elementId); - if (idx < 0) return 0; - - const carrotOffsetInSelectedSpan = getCaretOffsetWithin(active); - - let sumOfChars = 0; - for (let i = 0; i < idx; i++) { - sumOfChars += model[i].length; - } - - return sumOfChars + carrotOffsetInSelectedSpan; -}; - -export const getAbsoluteCaretPositionFromModel = (expressionModel: ExpressionModel[]): number => { - if (!expressionModel || expressionModel.length === 0) return 0; - - let absolutePosition = 0; - - for (const element of expressionModel) { - if (element.isFocused) { - absolutePosition += element.focusOffsetStart ?? 0; - break; - } - absolutePosition += element.length; - } - - return absolutePosition; -}; - -export const mapAbsoluteToModel = (model: ExpressionModel[], absolutePos: number): { index: number; offset: number } | null => { - if (!model || model.length === 0) return null; - let sum = 0; - for (let i = 0; i < model.length; i++) { - const len = model[i].length; - if (absolutePos <= sum + len) { - const local = Math.max(0, Math.min(len, absolutePos - sum)); - return { index: i, offset: local }; - } - sum += len; - } - return { index: model.length - 1, offset: model[model.length - 1].length }; -}; - -export const findNearestEditableIndex = (model: ExpressionModel[], startIndex: number, preferNext: boolean): number | null => { - if (!model || model.length === 0) return null; - if (!model[startIndex].isToken) return startIndex; - if (preferNext) { - for (let i = startIndex + 1; i < model.length; i++) { - if (!model[i].isToken) return i; - } - for (let i = startIndex - 1; i >= 0; i--) { - if (!model[i].isToken) return i; - } - } else { - for (let i = startIndex - 1; i >= 0; i--) { - if (!model[i].isToken) return i; - } - for (let i = startIndex + 1; i < model.length; i++) { - if (!model[i].isToken) return i; - } - } - return null; -}; - -export const updateExpressionModelWithCompletion = ( - expressionModel: ExpressionModel[] | undefined, - absoluteCaretPosition: number, - completionValue: string -): { updatedModel: ExpressionModel[]; updatedValue: string; newCursorPosition: number } | null => { - if (!expressionModel) return null; - - const mapped = mapAbsoluteToModel(expressionModel, absoluteCaretPosition); - - if (!mapped) return null; - - const { index, offset } = mapped; - const elementAboutToModify = expressionModel[index]; - - if (!elementAboutToModify || typeof elementAboutToModify.value !== 'string') return null; - - const textBeforeCaret = elementAboutToModify.value.substring(0, offset); - const textAfterCaret = elementAboutToModify.value.substring(offset); - - const lastWordMatch = textBeforeCaret.match(/\b\w+$/); - const lastWordStart = lastWordMatch ? textBeforeCaret.lastIndexOf(lastWordMatch[0]) : offset; - - const updatedText = - textBeforeCaret.substring(0, lastWordStart) + - completionValue + - textAfterCaret; + const absoluteStart = getAbsoluteColumnOffset(value, currentLine, currentChar); + const absoluteEnd = absoluteStart + length; - let sumOfCharsBefore = 0; - for (let i = 0; i < index; i++) { - sumOfCharsBefore += expressionModel[i].length; + tokenObjects.push({ id: tokenId++, start: absoluteStart, end: absoluteEnd, type: getTokenTypeFromIndex(type) }); } - const newCursorPosition = sumOfCharsBefore + lastWordStart + completionValue.length; - - const updatedModel = expressionModel.map((el, i) => - i === index ? { ...el, value: updatedText, length: updatedText.length } : el - ); + return tokenObjects; +} - const updatedValue = getTextValueFromExpressionModel(updatedModel); - return { updatedModel, updatedValue, newCursorPosition }; +export const getWordBeforeCursorPosition = (textBeforeCursor: string): string => { + const match = textBeforeCursor.match(/\b\w+$/); + const lastMatch = match ? match[match.length - 1] : ""; + return textBeforeCursor.endsWith(lastMatch) ? lastMatch : ''; }; -export const updateExpressionModelWithHelper = ( - expressionModel: ExpressionModel[] | undefined, - absoluteCaretPosition: number, - helperValue: string -): { updatedModel: ExpressionModel[]; updatedValue: string; newCursorPosition: number } | null => { - if (!expressionModel) return null; - - const mapped = mapAbsoluteToModel(expressionModel, absoluteCaretPosition); - - if (!mapped) return null; - - const { index, offset } = mapped; - const elementAboutToModify = expressionModel[index]; - - if (!elementAboutToModify || typeof elementAboutToModify.value !== 'string') return null; - - const textBeforeCaret = elementAboutToModify.value.substring(0, offset); - const textAfterCaret = elementAboutToModify.value.substring(offset); - - const updatedText = - textBeforeCaret + - helperValue + - textAfterCaret; - - let sumOfCharsBefore = 0; - for (let i = 0; i < index; i++) { - sumOfCharsBefore += expressionModel[i].length; +export const filterCompletionsByPrefixAndType = (completions: CompletionItem[], prefix: string): CompletionItem[] => { + if (!prefix) { + return completions.filter(completion => + completion.kind === 'field' + ); } - const newCursorPosition = sumOfCharsBefore + helperValue.length; - const updatedModel = expressionModel.map((el, i) => - i === index ? { ...el, value: updatedText, length: updatedText.length } : el + return completions.filter(completion => + (completion.kind === 'function' || completion.kind === 'variable' || completion.kind === 'field') && + completion.label.toLowerCase().startsWith(prefix.toLowerCase()) ); - - const updatedValue = getTextValueFromExpressionModel(updatedModel); - return { updatedModel, updatedValue, newCursorPosition }; }; -export const updateExpressionModelWithHelperValue = ( - expressionModel: ExpressionModel[] | undefined, - absoluteCaretPosition: number, - helperValue: string, - replaceEntireToken: boolean = false -): { updatedModel: ExpressionModel[]; updatedValue: string; newCursorPosition: number } | null => { - if (!expressionModel) return null; - let initializedModel = [] - if (expressionModel.length === 0) { - initializedModel = [{ - id: "1", - value: "", - isToken: false, - startColumn: 0, - startLine: 0, - length: 0, - type: 'literal', - isFocused: false, - focusOffsetStart: 0, - focusOffsetEnd: 0 - }] - } - else { - initializedModel = expressionModel - } - - const mapped = mapAbsoluteToModel(initializedModel, absoluteCaretPosition); - - if (!mapped) return null; - - const { index, offset } = mapped; - const elementAboutToModify = initializedModel[index]; - - if (!elementAboutToModify || typeof elementAboutToModify.value !== 'string') return null; - - let updatedText = ''; - if (replaceEntireToken) { - updatedText = helperValue; - } - else { - const textBeforeCaret = elementAboutToModify.value.substring(0, offset); - const textAfterCaret = elementAboutToModify.value.substring(offset); - updatedText = textBeforeCaret + helperValue + textAfterCaret; +// Maps a position from raw expression space to sanitized expression space +export const mapRawToSanitized = ( + rawPosition: number, + rawExpression: string, + sanitizedExpression: string +): number => { + if (rawExpression === sanitizedExpression) { + return rawPosition; } - let sumOfCharsBefore = 0; - for (let i = 0; i < index; i++) { - sumOfCharsBefore += initializedModel[i].length; + const sanitizedIndex = rawExpression.indexOf(sanitizedExpression); + if (sanitizedIndex === -1) { + return rawPosition; } - let newCursorPosition: number; - if (replaceEntireToken) { - newCursorPosition = sumOfCharsBefore + helperValue.length; - } else { - newCursorPosition = sumOfCharsBefore + offset + helperValue.length; + const prefixLength = sanitizedIndex; + if (rawPosition <= prefixLength) { + return 0; } - const updatedModel = initializedModel.map((el, i) => - i === index ? { ...el, value: updatedText, length: updatedText.length } : el - ); - - const updatedValue = getTextValueFromExpressionModel(updatedModel); - return { updatedModel, updatedValue, newCursorPosition }; + const mappedPosition = rawPosition - prefixLength; + return Math.min(mappedPosition, sanitizedExpression.length); }; -export const handleCompletionNavigation = ( - e: React.KeyboardEvent, - completionsLength: number, - selectedCompletionItem: number, - setSelectedCompletionItem: React.Dispatch>, - handleCompletionSelect: (completion: CompletionItem) => void, - setIsCompletionsOpen: React.Dispatch>, - completions: CompletionItem[] -) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setSelectedCompletionItem(prev => - prev < completionsLength - 1 ? prev + 1 : prev - ); - break; - case 'ArrowUp': - e.preventDefault(); - setSelectedCompletionItem(prev => - prev > 0 ? prev - 1 : -1 - ); - break; - case 'Enter': - if (selectedCompletionItem >= 0 && selectedCompletionItem < completionsLength) { - e.preventDefault(); - handleCompletionSelect(completions[selectedCompletionItem]); - } - break; - case 'Escape': - e.preventDefault(); - setIsCompletionsOpen(false); - break; - default: - break; +// Maps a position from sanitized expression space to raw expression space +export const mapSanitizedToRaw = ( + sanitizedPosition: number, + rawExpression: string, + sanitizedExpression: string +): number => { + if (rawExpression === sanitizedExpression) { + return sanitizedPosition; } -}; -export const setFocusInExpressionModel = ( - exprModel: ExpressionModel[], - mapped: { index: number; offset: number } | null, - preferNext: boolean = true -): ExpressionModel[] => { - if (!mapped) return exprModel; - - const editableIndex = findNearestEditableIndex(exprModel, mapped.index, preferNext); - if (editableIndex !== null) { - const boundedOffset = Math.max(0, Math.min(exprModel[editableIndex].length, mapped.offset)); - return exprModel.map((m, i) => ( - i === editableIndex - ? { ...m, isFocused: true, focusOffsetStart: boundedOffset, focusOffsetEnd: boundedOffset } - : { ...m, isFocused: false } - )); + const sanitizedIndex = rawExpression.indexOf(sanitizedExpression); + if (sanitizedIndex === -1) { + return sanitizedPosition; } - return exprModel; + const prefixLength = sanitizedIndex; + return sanitizedPosition + prefixLength; }; -export const setCaretPosition = (el: HTMLElement, position: number) => { - if (!el.firstChild) { - el.appendChild(document.createTextNode("")); - } - - let remaining = Math.max(0, position); - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); - let textNode: Text | null = null; - let posInNode = 0; - let node = walker.nextNode() as Text | null; - - while (node) { - const len = node.textContent ? node.textContent.length : 0; - if (remaining <= len) { - textNode = node; - posInNode = remaining; - break; - } - remaining -= len; - node = walker.nextNode() as Text | null; - } - - if (!textNode) { - const last = el.lastChild; - if (last && last.nodeType === Node.TEXT_NODE) { - textNode = last as Text; - posInNode = (textNode.textContent || "").length; - } else { - textNode = el.firstChild as Text; - posInNode = 0; - } - } - - const range = document.createRange(); - range.setStart(textNode, Math.max(0, Math.min(posInNode, (textNode.textContent || "").length))); - range.collapse(true); - const sel = window.getSelection(); - if (sel) { - sel.removeAllRanges(); - sel.addRange(range); - } -}; - -export const setSelectionRange = (el: HTMLElement, start: number, end: number) => { - if (!el.firstChild) { - el.appendChild(document.createTextNode("")); - } - - // Helper function to find text node and position - const findPosition = (position: number) => { - let remaining = Math.max(0, position); - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); - let textNode: Text | null = null; - let posInNode = 0; - let node = walker.nextNode() as Text | null; - - while (node) { - const len = node.textContent ? node.textContent.length : 0; - if (remaining <= len) { - textNode = node; - posInNode = remaining; - break; - } - remaining -= len; - node = walker.nextNode() as Text | null; - } - - if (!textNode) { - const last = el.lastChild; - if (last && last.nodeType === Node.TEXT_NODE) { - textNode = last as Text; - posInNode = (textNode.textContent || "").length; - } else { - textNode = el.firstChild as Text; - posInNode = 0; - } - } - - return { textNode, posInNode }; +/** + * Extracts metadata for document tokens + * Pattern: ${{content: value}} + */ +export const extractDocumentMetadata = ( + tokens: ParsedToken[], + startIndex: number, + endIndex: number, + docText: string +): TokenMetadata | null => { + const startToken = tokens[startIndex]; + const endToken = tokens[endIndex]; + const fullValue = docText.substring(startToken.start, endToken.end); + const documentRegex = /\$\{\{content:\s*([^}]+)\}\}/; + const match = fullValue.match(documentRegex); + + if (!match) { + return null; + } + + const documentType = match[1] as DocumentType; + const content = match[2].trim(); + + return { + content, + fullValue, + documentType }; - - const startPos = findPosition(start); - const endPos = findPosition(end); - - const range = document.createRange(); - range.setStart(startPos.textNode, Math.max(0, Math.min(startPos.posInNode, (startPos.textNode.textContent || "").length))); - range.setEnd(endPos.textNode, Math.max(0, Math.min(endPos.posInNode, (endPos.textNode.textContent || "").length))); - - const sel = window.getSelection(); - if (sel) { - sel.removeAllRanges(); - sel.addRange(range); - } -}; - -export const handleKeyDownInTextElement = ( - e: React.KeyboardEvent, - expressionModel: ExpressionModel[], - index: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void, - host?: HTMLSpanElement | null -) => { - if (!host) return; - - const caretOffset = getCaretOffsetWithin(host); - const textLength = host.textContent?.length || 0; - - switch (e.key) { - case 'Backspace': - handleBackspace(e, expressionModel, index, caretOffset, onExpressionChange); - break; - case 'Delete': - handleDelete(e, expressionModel, index, caretOffset, textLength, onExpressionChange); - break; - case 'ArrowRight': - handleArrowRight(e, expressionModel, index, caretOffset, textLength, onExpressionChange); - break; - case 'ArrowLeft': - handleArrowLeft(e, expressionModel, index, caretOffset, onExpressionChange); - break; - } -}; - -const handleBackspace = ( - e: React.KeyboardEvent, - expressionModel: ExpressionModel[], - index: number, - caretOffset: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - if (caretOffset === 0) { - handleBackspaceAtStart(e, expressionModel, index, onExpressionChange); - } -}; - -const handleBackspaceAtStart = ( - e: React.KeyboardEvent, - expressionModel: ExpressionModel[], - index: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - e.preventDefault(); - e.stopPropagation(); - - if (index === 0 || index >= expressionModel.length) return; - - let newExpressionModel = [...expressionModel]; - const tokensToRemove: number[] = []; - - // Scan backwards to find first non-token or collect all tokens - for (let i = index - 1; i >= 0; i--) { - if (newExpressionModel[i].isToken) { - tokensToRemove.push(i); - } else { - mergeWithPreviousElement( - expressionModel, - index, - i, - tokensToRemove, - onExpressionChange - ); - return; - } - } - - // If we only found tokens, remove them - if (tokensToRemove.length > 0) { - newExpressionModel = newExpressionModel.filter( - (_, idx) => !tokensToRemove.includes(idx) - ); - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, BACKSPACE_MARKER); - } }; -const mergeWithPreviousElement = ( - expressionModel: ExpressionModel[], - currentIndex: number, - previousIndex: number, - tokensToRemove: number[], - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - const currentElement = expressionModel[currentIndex]; - const shouldRemoveCurrent = currentElement.length === 0; - - const newExpressionModel = expressionModel - .map((el, idx) => { - if (idx === previousIndex) { - return { ...el, isFocused: true, focusOffsetStart: el.length }; - } - return el; - }) - .filter((_, idx) => { - if (idx === currentIndex) return !shouldRemoveCurrent; - return !tokensToRemove.includes(idx); - }); - - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, BACKSPACE_MARKER); -}; - -const handleDelete = ( - e: React.KeyboardEvent, - expressionModel: ExpressionModel[], - index: number, - caretOffset: number, - textLength: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - if (caretOffset !== textLength) return; - - e.preventDefault(); - e.stopPropagation(); - - const currentElement = expressionModel[index]; - if (!currentElement || index === expressionModel.length - 1) return; - - let newExpressionModel = [...expressionModel]; - const tokensToRemove: number[] = []; - - // Scan forward to find first non-token or collect all tokens - for (let i = index + 1; i < expressionModel.length; i++) { - if (expressionModel[i].isToken) { - tokensToRemove.push(i); - } else { - deleteFirstCharFromNextElement( - expressionModel, - index, - i, - tokensToRemove, - onExpressionChange - ); - return; - } - } - - // If we only found tokens, remove them - if (tokensToRemove.length > 0) { - newExpressionModel = newExpressionModel.filter( - (_, idx) => !tokensToRemove.includes(idx) - ); - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, DELETE_MARKER); - } +/** + * Extracts metadata for variable tokens + * Pattern: ${variableName} + */ +export const extractVariableMetadata = ( + tokens: ParsedToken[], + startIndex: number, + endIndex: number, + docText: string +): TokenMetadata | null => { + const startToken = tokens[startIndex]; + const endToken = tokens[endIndex]; + const fullValue = docText.substring(startToken.start, endToken.end); + const variableRegex = /\$\{([^}]+)\}/; + const match = fullValue.match(variableRegex); + + if (!match) { + return null; + } + + const content = match[1].trim(); + + return { + content, + fullValue + }; }; -const deleteFirstCharFromNextElement = ( - expressionModel: ExpressionModel[], - currentIndex: number, - nextIndex: number, - tokensToRemove: number[], - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - const currentElement = expressionModel[currentIndex]; - const nextElement = expressionModel[nextIndex]; - const updatedNextValue = nextElement.value.slice(1); - const shouldRemoveNext = updatedNextValue.length === 0; - - const newExpressionModel = expressionModel - .map((el, idx) => { - if (idx === currentIndex) { - return { ...el, isFocused: true, focusOffsetStart: el.length, focusOffsetEnd: el.length }; - } else if (idx === nextIndex && !shouldRemoveNext) { - return { ...el, value: updatedNextValue, length: updatedNextValue.length, isFocused: false, focusOffsetStart: 0, focusOffsetEnd: 0 }; +// Token patterns for detecting compound token sequences +// Patterns are checked in priority order (higher priority number = higher priority) +export const TOKEN_PATTERNS: readonly TokenPattern[] = [ + { + name: TokenType.DOCUMENT, + sequence: [TokenType.START_EVENT, TokenType.TYPE_CAST, TokenType.VALUE, TokenType.END_EVENT], + extractor: extractDocumentMetadata, + priority: 2 + }, + { + name: TokenType.VARIABLE, + sequence: [TokenType.START_EVENT, TokenType.VARIABLE, TokenType.END_EVENT], + extractor: extractVariableMetadata, + priority: 1 + } +]; + +// Detects compound token sequences based on defined patterns +export const detectTokenPatterns = ( + tokens: ParsedToken[], + docText: string +): CompoundTokenSequence[] => { + const compounds: CompoundTokenSequence[] = []; + const usedIndices = new Set(); + + // Sort patterns by priority (higher priority first) + const sortedPatterns = [...TOKEN_PATTERNS].sort((a, b) => b.priority - a.priority); + + // Try to match each pattern + for (const pattern of sortedPatterns) { + const sequenceLength = pattern.sequence.length; + + // Sliding window to find matching sequences + for (let i = 0; i <= tokens.length - sequenceLength; i++) { + // Skip if any token in this range is already used + if (Array.from({ length: sequenceLength }, (_, j) => i + j).some(idx => usedIndices.has(idx))) { + continue; } - return el; - }) - .filter((_, idx) => { - if (idx === nextIndex) return !shouldRemoveNext; - return !tokensToRemove.includes(idx); - }); - - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, DELETE_MARKER); -}; - -const handleArrowRight = ( - e: React.KeyboardEvent, - expressionModel: ExpressionModel[], - index: number, - caretOffset: number, - textLength: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - e.preventDefault(); - e.stopPropagation(); - - if (caretOffset === textLength) { - moveToNextElement(expressionModel, index, onExpressionChange); - } else { - moveCaretForward(expressionModel, index, caretOffset, onExpressionChange); - } -}; - -const moveToNextElement = ( - expressionModel: ExpressionModel[], - index: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - if (index < 0 || index >= expressionModel.length - 1) return; - - for (let i = index + 1; i < expressionModel.length; i++) { - if (!expressionModel[i].isToken) { - const newExpressionModel = expressionModel.map((el, idx) => { - if (idx === i) { - return { ...el, isFocused: true, focusOffsetStart: 0, focusOffsetEnd: 0 }; - } else if (idx === index) { - return { ...el, isFocused: false, focusOffsetStart: undefined, focusOffsetEnd: undefined }; - } - return el; - }); - - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, ARROW_RIGHT_MARKER); - return; - } - } -}; - -const moveCaretForward = ( - expressionModel: ExpressionModel[], - index: number, - caretOffset: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - const newExpressionModel = expressionModel.map((el, idx) => { - if (idx === index) { - return { ...el, isFocused: true, focusOffsetStart: Math.max(0, caretOffset + 1), focusOffsetEnd: Math.max(0, caretOffset + 1) }; - } - return el; - }); - - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, ARROW_LEFT_MARKER); -}; -const handleArrowLeft = ( - e: React.KeyboardEvent, - expressionModel: ExpressionModel[], - index: number, - caretOffset: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - e.preventDefault(); - e.stopPropagation(); - - if (caretOffset === 0) { - moveToPreviousElement(expressionModel, index, onExpressionChange); - } else { - moveCaretBackward(expressionModel, index, caretOffset, onExpressionChange); - } -}; - -const moveToPreviousElement = ( - expressionModel: ExpressionModel[], - index: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - if (index <= 0 || index >= expressionModel.length) return; - - // Find previous non-token element - for (let i = index - 1; i >= 0; i--) { - if (!expressionModel[i].isToken) { - const newExpressionModel = expressionModel.map((el, idx) => { - if (idx === i) { - return { ...el, isFocused: true, focusOffsetStart: el.length, focusOffsetEnd: el.length }; - } else if (idx === index) { - return { ...el, isFocused: false, focusOffsetStart: undefined, focusOffsetEnd: undefined }; - } - return el; + // Check if token sequence matches pattern + const matches = pattern.sequence.every((expectedType, offset) => { + return tokens[i + offset]?.type === expectedType; }); - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, ARROW_LEFT_MARKER); - return; - } - } -}; - -const moveCaretBackward = ( - expressionModel: ExpressionModel[], - index: number, - caretOffset: number, - onExpressionChange?: (updatedExpressionModel: ExpressionModel[], cursorPosition?: number, lastTypedText?: string) => void -) => { - const newExpressionModel = expressionModel.map((el, idx) => { - if (idx === index) { - return { ...el, isFocused: true, focusOffsetStart: Math.max(0, caretOffset - 1), focusOffsetEnd: Math.max(0, caretOffset - 1) }; - } - return el; - }); - - const newCursorPosition = getAbsoluteCaretPositionFromModel(newExpressionModel); - onExpressionChange?.(newExpressionModel, newCursorPosition, ARROW_LEFT_MARKER); -}; - -export const getWordBeforeCursor = (expressionModel: ExpressionModel[]): string => { - const absoluteCaretPosition = getAbsoluteCaretPositionFromModel(expressionModel); - const fullText = getTextValueFromExpressionModel(expressionModel); - const fullTextUpToCursor = fullText.slice(0, absoluteCaretPosition); - - const match = fullTextUpToCursor.match(/\b\w+$/); - - const lastMatch = match ? match[match.length - 1] : ""; - return fullTextUpToCursor.endsWith(lastMatch) ? lastMatch : ''; -}; - - - -export const setCursorPositionToExpressionModel = (expressionModel: ExpressionModel[], cursorPosition: number): ExpressionModel[] => { - const newExpressionModel = []; - let i = 0; - let foundTarget = false; - - while (i < expressionModel.length) { - const element = expressionModel[i]; - if (!foundTarget && cursorPosition <= element.length) { - foundTarget = true; - - if (element.isToken) { - if (expressionModel.length <= i + 1) { - - newExpressionModel.push({ - id: element.id + "1", - isFocused: true, - focusOffsetStart: 0, - focusOffsetEnd: 0, - value: ' ', - isToken: element.isToken, - startColumn: element.startColumn, - startLine: element.startLine, - length: element.length, - type: element.type - }); - return newExpressionModel; - } - else { - const nextElement = expressionModel[i + 1]; - newExpressionModel.push({ - ...element, - isFocused: false, - focusOffsetStart: undefined, - focusOffsetEnd: undefined - }); - newExpressionModel.push({ - ...nextElement, - isFocused: true, - focusOffsetStart: 0, - focusOffsetEnd: 0 + if (matches) { + const startIndex = i; + const endIndex = i + sequenceLength - 1; + + // Extract metadata using pattern's extractor + const metadata = pattern.extractor(tokens, startIndex, endIndex, docText); + + if (metadata) { + compounds.push({ + startIndex, + endIndex, + tokenType: pattern.name, + displayText: metadata.content, + metadata, + start: tokens[startIndex].start, + end: tokens[endIndex].end }); - i += 2; - } - } - else { - newExpressionModel.push({ - ...element, - isFocused: true, - focusOffsetStart: cursorPosition, - focusOffsetEnd: cursorPosition - }); - i += 1; - } - } - else { - newExpressionModel.push({ - ...element, - isFocused: false, - focusOffsetStart: undefined, - focusOffsetEnd: undefined - }); - i += 1; - if (!foundTarget) { - cursorPosition -= element.length; - } - } - } - return newExpressionModel; -}; - -export const updateTokens = (tokens: number[], cursorPosition: number, cursorChange: number, previousFullText: string): number[] => { - const updatedTokens: number[] = []; - const tokenChunks = getTokenChunks(tokens); - - let currentLine = 0; - let currentChar = 0; - let adjustedCurrentLine = false; - - for (let i = 0; i < tokenChunks.length; i++) { - const chunk = tokenChunks[i]; - const deltaLine = chunk[TOKEN_LINE_OFFSET_INDEX]; - const deltaStartChar = chunk[TOKEN_START_CHAR_OFFSET_INDEX]; - const tokenLength = chunk[TOKEN_LENGTH_INDEX]; - const tokenType = chunk[3]; - const tokenModifiers = chunk[4]; - - if (deltaLine > 0) { - adjustedCurrentLine = false; - } - currentLine += deltaLine; - if (deltaLine === 0) { - currentChar += deltaStartChar; - } else { - currentChar = deltaStartChar; - } - - const tokenAbsoluteOffset = getAbsoluteColumnOffset(previousFullText, currentLine, currentChar); - - let adjustedDeltaStartChar = deltaStartChar; - if (!adjustedCurrentLine && tokenAbsoluteOffset >= cursorPosition) { - adjustedDeltaStartChar += cursorChange; - adjustedCurrentLine = true; - } - - updatedTokens.push(deltaLine, adjustedDeltaStartChar, tokenLength, tokenType, tokenModifiers); - } - - return updatedTokens; -}; - -export const getModelWithModifiedParamsChip = (expressionModel: ExpressionModel[]): ExpressionModel[] => { - return expressionModel.map(el => { - if (el.type === 'parameter' && el.value.length === 0) { - return { - ...el, - value: '$1', - length: 2 + // Mark tokens as used + for (let j = startIndex; j <= endIndex; j++) { + usedIndices.add(j); + } + } } } - return el; } - ) -} - -export const isBetween = (a: number, b: number, target: number): boolean => { - return target >= Math.min(a, b) && target <= Math.max(a, b); -} - - -//new - -export type ParsedToken = { - id:number; - start: number; - end: number; - type: TokenType; -} - -export const getParsedExpressionTokens = (tokens: number[], value: string) => { - const chunks = getTokenChunks(tokens); - let currentLine = 0; - let currentChar = 0; - const tokenObjects: ParsedToken[] = []; - - let tokenId = 0; - for (let chunk of chunks) { - const deltaLine = chunk[TOKEN_LINE_OFFSET_INDEX]; - const deltaStartChar = chunk[TOKEN_START_CHAR_OFFSET_INDEX]; - const length = chunk[TOKEN_LENGTH_INDEX]; - const type = chunk[3]; - - currentLine += deltaLine; - if (deltaLine === 0) { - currentChar += deltaStartChar; - } else { - currentChar = deltaStartChar; - } - - const absoluteStart = getAbsoluteColumnOffset(value, currentLine, currentChar); - const absoluteEnd = absoluteStart + length; - - tokenObjects.push({ id: tokenId++, start: absoluteStart, end: absoluteEnd, type: getTokenTypeFromIndex(type) }); - } - return tokenObjects; -} - -export const getWordBeforeCursorPosition = (textBeforeCursor: string): string => { - const match = textBeforeCursor.match(/\b\w+$/); - const lastMatch = match ? match[match.length - 1] : ""; - return textBeforeCursor.endsWith(lastMatch) ? lastMatch : ''; -}; - -export const filterCompletionsByPrefixAndType = (completions: CompletionItem[], prefix: string): CompletionItem[] => { - if (!prefix) { - return completions.filter(completion => - completion.kind === 'field' - ); - } - - return completions.filter(completion => - (completion.kind === 'function' || completion.kind === 'variable' || completion.kind === 'field') && - completion.label.toLowerCase().startsWith(prefix.toLowerCase()) - ); + return compounds; }; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/RawExpressionEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/RawExpressionEditor.tsx index 0a86682b63f..67eabf103ab 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/RawExpressionEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/RawExpressionEditor.tsx @@ -20,32 +20,66 @@ import React from "react"; import { useFormContext } from "../../context"; import { ContextAwareExpressionEditorProps, ExpressionEditor } from "./ExpressionEditor"; +interface TemplateConfig { + prefix: string; + suffix: string; +} + +const TEMPLATE_CONFIGS: Record = { + "ai:Prompt": { + prefix: "`", + suffix: "`" + }, + "string": { + prefix: "string `", + suffix: "`" + } +}; + +const getTemplateConfig = (valueTypeConstraint?: string | string[]): TemplateConfig => { + if (!valueTypeConstraint) { + return { prefix: "`", suffix: "`" }; + } + const constraint = Array.isArray(valueTypeConstraint) ? valueTypeConstraint[0] : valueTypeConstraint; + return TEMPLATE_CONFIGS[constraint] || { prefix: "`", suffix: "`" }; +}; + export const ContextAwareRawExpressionEditor = (props: ContextAwareExpressionEditorProps) => { const { form, expressionEditor, targetLineRange, fileName } = useFormContext(); + const templateConfig = getTemplateConfig(props.field.valueTypeConstraint); + + const getSanitizedExp = (value: string) => { + if (!value) { + return value; + } + const { prefix, suffix } = templateConfig; + if (value.startsWith(prefix) && value.endsWith(suffix)) { + return value.slice(prefix.length, -suffix.length); + } + return value; + }; + + const getRawExp = (value: string) => { + if (!value) { + return value; + } + const { prefix, suffix } = templateConfig; + if (!value.startsWith(prefix) && !value.endsWith(suffix)) { + return `${prefix}${value}${suffix}`; + } + return value; + }; return ( ); }; - -const getSanitizedExp = (value: string) => { - if (value) { - return value.replace(/`/g, ""); - } - return value; -}; - -const getRawExp = (value: string) => { - if (value && !value.startsWith("`") && !value.endsWith("`")) { - return `\`${value}\``; - } - return value; -}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts index b00f596db01..a541f9d82c8 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts @@ -28,3 +28,4 @@ export * from "./FormMapEditor"; export * from "./FieldContext"; export * from "./MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; export { getPropertyFromFormField } from "./utils"; +export { InputMode } from "./MultiModeExpressionEditor/ChipExpressionEditor/types"; \ No newline at end of file diff --git a/workspaces/ballerina/ballerina-visualizer/src/Context.tsx b/workspaces/ballerina/ballerina-visualizer/src/Context.tsx index 04d5591d8e5..583c049ed97 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/Context.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/Context.tsx @@ -140,6 +140,7 @@ export const POPUP_IDS = { RECORD_CONFIG: "RECORD_CONFIG", LIBRARY_BROWSER: "LIBRARY_BROWSER", ATTACH_LISTENER: "ATTACH_LISTENER", + DOCUMENT_URL: "DOCUMENT_URL", } as const; type ModalStackItem = { diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx index bd5d009820b..fbd8332cd34 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx @@ -50,6 +50,7 @@ import { PanelContainer, FormImports, HelperpaneOnChangeOptions, + InputMode, } from "@wso2/ballerina-side-panel"; import { useRpcContext } from "@wso2/ballerina-rpc-client"; import { @@ -898,6 +899,7 @@ export const FormGenerator = forwardRef(func recordTypeField?: RecordTypeField, isAssignIdentifier?: boolean, defaultValueTypeConstraint?: string, + inputMode?: InputMode, ) => { const handleHelperPaneClose = () => { debouncedRetrieveCompletions.cancel(); @@ -927,7 +929,8 @@ export const FormGenerator = forwardRef(func valueTypeConstraint: defaultValueTypeConstraint, handleRetrieveCompletions: handleRetrieveCompletions, forcedValueTypeConstraint: valueTypeConstraints, - handleValueTypeConstChange: handleValueTypeConstChange + handleValueTypeConstChange: handleValueTypeConstChange, + inputMode: inputMode, }); }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx index ea1545be392..1878c134cdb 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx @@ -43,7 +43,8 @@ import { ExpressionFormField, FormExpressionEditorProps, FormImports, - HelperpaneOnChangeOptions + HelperpaneOnChangeOptions, + InputMode } from "@wso2/ballerina-side-panel"; import { useRpcContext } from "@wso2/ballerina-rpc-client"; import { CompletionItem, FormExpressionEditorRef, HelperPaneHeight, Overlay, ThemeColors } from "@wso2/ui-toolkit"; @@ -663,6 +664,7 @@ export function FormGeneratorNew(props: FormProps) { recordTypeField?: RecordTypeField, isAssignIdentifier?: boolean, valueTypeConstraint?: string, + inputMode?: InputMode, ) => { const handleHelperPaneClose = () => { debouncedRetrieveCompletions.cancel(); @@ -692,6 +694,7 @@ export function FormGeneratorNew(props: FormProps) { handleRetrieveCompletions: handleRetrieveCompletions, handleValueTypeConstChange: handleValueTypeConstChange, forcedValueTypeConstraint: valueTypeConstraints, + inputMode: inputMode, }); }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Configurables.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Configurables.tsx index db229a3cd64..ee55b5f1e8c 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Configurables.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Configurables.tsx @@ -33,6 +33,8 @@ import { EmptyItemsPlaceHolder } from "../Components/EmptyItemsPlaceHolder"; import { HelperPaneCustom } from "@wso2/ui-toolkit"; import { useHelperPaneNavigation } from "../hooks/useHelperPaneNavigation"; import { BreadcrumbNavigation } from "../Components/BreadcrumbNavigation"; +import { InputMode } from "@wso2/ballerina-side-panel"; +import { wrapInTemplateInterpolation } from "../utils/utils"; type ConfigVariablesState = { [category: string]: { @@ -52,11 +54,12 @@ type ConfigurablesPageProps = { fileName: string; targetLineRange: LineRange; onClose?: () => void; + inputMode?: InputMode; } export const Configurables = (props: ConfigurablesPageProps) => { - const { onChange, onClose, fileName, targetLineRange } = props; + const { onChange, onClose, fileName, targetLineRange, inputMode } = props; const { rpcClient } = useRpcContext(); const { breadCrumbSteps, navigateToNext, navigateToBreadcrumb, isAtRoot } = useHelperPaneNavigation("Configurables"); @@ -116,7 +119,7 @@ export const Configurables = (props: ConfigurablesPageProps) => { let errorMsg: string = ''; setIsLoading(true); - + // Only apply minimum loading time if we don't have any config variables yet const shouldShowMinLoader = Object.keys(configVariables).length === 0 && !showContent; const minLoadingTime = shouldShowMinLoader ? new Promise(resolve => setTimeout(resolve, 500)) : Promise.resolve(); @@ -169,7 +172,9 @@ export const Configurables = (props: ConfigurablesPageProps) => { } const handleItemClicked = (name: string) => { - onChange(name, false) + // Wrap in template interpolation if in template mode + const wrappedName = wrapInTemplateInterpolation(name, inputMode); + onChange(wrappedName, false) onClose && onClose(); } @@ -204,7 +209,7 @@ export const Configurables = (props: ConfigurablesPageProps) => { }}> navigateToBreadcrumb(step, onChange)} + onNavigateToBreadcrumb={(step) => navigateToBreadcrumb(step)} /> {(() => { const filteredCategories = translateToArrayFormat(configVariables) @@ -212,7 +217,7 @@ export const Configurables = (props: ConfigurablesPageProps) => { Array.isArray(category.items) && category.items.some(sub => Array.isArray(sub.items) && sub.items.length > 0) ); - + // Count total items across all categories const totalItemsCount = filteredCategories.reduce((total, category) => { return total + category.items.reduce((subTotal, subCategory) => { @@ -230,7 +235,7 @@ export const Configurables = (props: ConfigurablesPageProps) => { ); })()} - + {isLoading || !showContent ? ( @@ -255,11 +260,11 @@ export const Configurables = (props: ConfigurablesPageProps) => { })).filter(subCategory => subCategory.items.length > 0) })).filter(category => category.items.length > 0); } - + if (filteredCategories.length === 0) { return ; } - + return ( <> {filteredCategories.map(category => ( @@ -294,7 +299,7 @@ export const Configurables = (props: ConfigurablesPageProps) => { ) : (
{subCategory.items.map((item: ConfigVariable) => ( - { handleItemClicked(item?.properties?.variable?.value as string) }} > diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/DocumentConfig.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/DocumentConfig.tsx new file mode 100644 index 00000000000..fbef883dad6 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/DocumentConfig.tsx @@ -0,0 +1,299 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useSlidingPane, CompletionItem, Divider, HelperPaneCustom, SearchBox, Typography, ThemeColors, Button, TextField } from "@wso2/ui-toolkit"; +import { ExpressionProperty, LineRange } from "@wso2/ballerina-core"; +import { useEffect, useMemo, useState, useCallback } from "react"; +import { getPropertyFromFormField, useFieldContext, InputMode } from "@wso2/ballerina-side-panel"; +import { ExpandableList } from "../Components/ExpandableList"; +import { ScrollableContainer } from "../Components/ScrollableContainer"; +import { EmptyItemsPlaceHolder } from "../Components/EmptyItemsPlaceHolder"; +import { useHelperPaneNavigation, BreadCrumbStep } from "../hooks/useHelperPaneNavigation"; +import { BreadcrumbNavigation } from "../Components/BreadcrumbNavigation"; +import { AIDocumentType } from "./Documents"; +import { VariableItem } from "./Variables"; +import FooterButtons from "../Components/FooterButtons"; +import { POPUP_IDS, useModalStack } from "../../../../Context"; + +type DocumentConfigProps = { + onChange: (value: string, isRecordConfigureChange: boolean, shouldKeepHelper?: boolean) => void; + onClose: () => void; + targetLineRange: LineRange; + filteredCompletions: CompletionItem[]; + currentValue: string; + handleRetrieveCompletions: (value: string, property: ExpressionProperty, offset: number, triggerCharacter?: string) => Promise; + isInModal?: boolean; + inputMode?: InputMode; +}; + +const AI_DOCUMENT_TYPES = Object.values(AIDocumentType); + +// Helper function to wrap content in document structure +const wrapInDocumentType = (documentType: AIDocumentType, content: string, addInterpolation: boolean = true): string => { + const docStructure = `<${documentType}>{content: ${content}}`; + return addInterpolation ? `\${${docStructure}}` : docStructure; +}; + +export const DocumentConfig = ({ onChange, onClose, targetLineRange, filteredCompletions, currentValue, handleRetrieveCompletions, isInModal, inputMode }: DocumentConfigProps) => { + const { getParams } = useSlidingPane(); + const params = getParams(); + const documentType = (params?.documentType as AIDocumentType) || AIDocumentType.FileDocument; + const [searchValue, setSearchValue] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [showContent, setShowContent] = useState(false); + const { breadCrumbSteps, navigateToNext, navigateToBreadcrumb, getCurrentNavigationPath } = useHelperPaneNavigation(`${documentType.charAt(0).toUpperCase() + documentType.slice(1)} Document`); + const { addModal, closeModal } = useModalStack(); + + const { field, triggerCharacters } = useFieldContext(); + + // Use navigation path for completions instead of currentValue + const navigationPath = useMemo(() => getCurrentNavigationPath(), [breadCrumbSteps]); + const completionContext = useMemo(() => + navigationPath ? navigationPath + '.' : currentValue, + [navigationPath, currentValue] + ); + + useEffect(() => { + setIsLoading(true); + const triggerCharacter = + completionContext.length > 0 + ? triggerCharacters.find((char) => completionContext[completionContext.length - 1] === char) + : undefined; + + // Only apply minimum loading time if we don't have any completions yet + const shouldShowMinLoader = filteredCompletions.length === 0 && !showContent; + const minLoadingTime = shouldShowMinLoader ? new Promise(resolve => setTimeout(resolve, 500)) : Promise.resolve(); + + // When navigating, use length as offset to position cursor after the dot + // When at root, use 0 to get all completions + const offset = navigationPath ? completionContext.length : 0; + + Promise.all([ + handleRetrieveCompletions(completionContext, getPropertyFromFormField(field), offset, triggerCharacter), + minLoadingTime + ]).finally(() => { + setIsLoading(false); + setShowContent(true); + }); + + }, [targetLineRange, breadCrumbSteps, completionContext]); + + // Get allowed types based on document type + const getAllowedTypes = (docType: AIDocumentType): string[] => { + const baseTypes = ["string", "ai:Url", "byte[]"]; + + switch (docType) { + case AIDocumentType.FileDocument: + return [...baseTypes, AIDocumentType.FileDocument]; + case AIDocumentType.ImageDocument: + return [...baseTypes, AIDocumentType.ImageDocument]; + case AIDocumentType.AudioDocument: + return [...baseTypes, AIDocumentType.AudioDocument]; + default: + return baseTypes; + } + }; + + const dropdownItems = useMemo(() => { + const allowedTypes = getAllowedTypes(documentType); + const excludedDescriptions = ["Configurable", "Parameter", "Listener", "Client"]; + const otherAIDocTypes = AI_DOCUMENT_TYPES.filter(type => !allowedTypes.includes(type)); + + return filteredCompletions.filter((completion) => { + const { kind, label, description = "", labelDetails } = completion; + const labelDesc = labelDetails?.description || ""; + + // Must be a field or variable, but not "self" + if ((kind !== "field" && kind !== "variable") || label === "self") return false; + + // Exclude certain description types + if (excludedDescriptions.some(desc => labelDesc.includes(desc))) return false; + + // Exclude other AI document types + if (otherAIDocTypes.some(type => description.includes(type) || labelDesc.includes(type))) return false; + + // Include if matches allowed types or is a record + return allowedTypes.some(type => description.includes(type) || labelDesc.includes(type)) + || labelDesc.includes("Record"); + }); + }, [filteredCompletions, documentType]); + + const filteredDropDownItems = useMemo(() => { + if (!searchValue || searchValue.length === 0) return dropdownItems; + return dropdownItems.filter((item) => + item.label.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue, dropdownItems]); + + const handleSearch = (searchText: string) => { + setSearchValue(searchText); + }; + + const handleItemSelect = (value: string, item: CompletionItem) => { + const description = item.description || ""; + const labelDescription = item.labelDetails?.description || ""; + const typeInfo = description || labelDescription; + + // Build full path from navigation + const fullPath = navigationPath ? `${navigationPath}.${value}` : value; + + // Check if the variable is already an AI document type + const isAIDocumentType = AI_DOCUMENT_TYPES.some(type => typeInfo.includes(type)); + + // Check if the type is string or byte[] - these need to be wrapped with type casting + const needsTypeCasting = typeInfo.includes("string") || typeInfo.includes("byte[]") || typeInfo.includes("ai:Url"); + + // Check if we're in template mode + const isTemplateMode = inputMode === InputMode.TEMPLATE; + + if (isAIDocumentType) { + // For AI document types, wrap in string interpolation only in template mode + if (isTemplateMode) { + onChange(`\${${fullPath}}`, false); + } else { + onChange(fullPath, false); + } + } else if (needsTypeCasting) { + // Wrap the variable in the document structure with or without interpolation based on mode + const wrappedValue = wrapInDocumentType(documentType, fullPath, isTemplateMode); + onChange(wrappedValue, false); + } else { + // For other types (records, etc.), insert directly + onChange(fullPath, false); + } + }; + + const handleVariablesMoreIconClick = (value: string) => { + navigateToNext(value, navigationPath); + }; + + const handleBreadCrumbItemClicked = (step: BreadCrumbStep) => { + navigateToBreadcrumb(step); + }; + + const ExpandableListItems = () => { + return ( + <> + { + filteredDropDownItems.map((item) => ( + + )) + } + + ); + }; + + const URLInputModal = () => { + const [url, setUrl] = useState(""); + + const handleCreate = () => { + if (!url.trim()) { + return; + } + const isTemplateMode = inputMode === InputMode.TEMPLATE; + const wrappedValue = wrapInDocumentType(documentType, `"${url.trim()}"`, isTemplateMode); + onChange(wrappedValue, false, false); + closeModal(POPUP_IDS.DOCUMENT_URL); + }; + + return ( +
+ { + if (e.key === 'Enter') { + handleCreate(); + } + }} + /> +
+ +
+
+ ); + }; + + const handleAddFromURL = () => { + addModal( + , + POPUP_IDS.DOCUMENT_URL, + "Insert Hardcoded URL", + 220, + 355 + ); + }; + + return ( +
+ + {dropdownItems.length >= 6 && ( +
+ +
+ )} + + + {isLoading || !showContent ? ( + + ) : ( + <> + {filteredDropDownItems.length === 0 ? ( + + ) : ( + + + + )} + + )} + + + + {!isInModal && ( +
+ +
+ )} +
+ ); +}; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Documents.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Documents.tsx new file mode 100644 index 00000000000..048637a354c --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Documents.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Icon, SlidingPaneNavContainer, Typography } from "@wso2/ui-toolkit"; +import { ExpandableList } from "../Components/ExpandableList"; + +export enum AIDocumentType { + FileDocument = 'ai:FileDocument', + ImageDocument = 'ai:ImageDocument', + AudioDocument = 'ai:AudioDocument' +} + +export const Documents = () => { + return ( +
+
+ + + + + + File Document + + + + + + + + + Image Document + + + + + + + + + Audio Document + + + + +
+
+ ); +}; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Functions.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Functions.tsx index 9e1661f9de7..431a30c95e6 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Functions.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Functions.tsx @@ -17,7 +17,7 @@ */ import { useRpcContext } from "@wso2/ballerina-rpc-client"; -import { HelperPaneCompletionItem, HelperPaneFunctionInfo } from "@wso2/ballerina-side-panel"; +import { HelperPaneCompletionItem, HelperPaneFunctionInfo, InputMode } from "@wso2/ballerina-side-panel"; import { debounce } from "lodash"; import { useRef, useState, useCallback, RefObject, useEffect } from "react"; import { convertToHelperPaneFunction, extractFunctionInsertText } from "../../../../utils/bi"; @@ -36,6 +36,7 @@ import { FunctionFormStatic } from "../../FunctionFormStatic"; import { POPUP_IDS, useModalStack } from "../../../../Context"; import { HelperPaneIconType, getHelperPaneIcon } from "../utils/iconUtils"; import { HelperPaneListItem } from "../Components/HelperPaneListItem"; +import { wrapInTemplateInterpolation } from "../utils/utils"; type FunctionsPageProps = { fieldKey: string; @@ -45,7 +46,8 @@ type FunctionsPageProps = { onClose: () => void; onChange: (insertText: CompletionInsertText | string) => void; updateImports: (key: string, imports: { [key: string]: string }) => void; - selectedType?: CompletionItem + selectedType?: CompletionItem; + inputMode?: InputMode; }; export const FunctionsPage = ({ @@ -56,7 +58,8 @@ export const FunctionsPage = ({ onClose, onChange, updateImports, - selectedType + selectedType, + inputMode }: FunctionsPageProps) => { const { rpcClient } = useRpcContext(); @@ -175,7 +178,8 @@ export const FunctionsPage = ({ const handleFunctionItemSelect = async (item: HelperPaneCompletionItem) => { const { value, cursorOffset } = await onFunctionItemSelect(item); - onChange({ value, cursorOffset }); + const wrappedValue = wrapInTemplateInterpolation(value, inputMode); + onChange({ value: wrappedValue, cursorOffset }); onClose(); }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Inputs.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Inputs.tsx index 471e0a089de..450d3d7a174 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Inputs.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Inputs.tsx @@ -21,11 +21,12 @@ import { TypeIndicator } from "../Components/TypeIndicator" import { ExpressionProperty, LineRange } from "@wso2/ballerina-core" import { Codicon, CompletionItem, HelperPaneCustom, SearchBox, ThemeColors, Tooltip, Typography } from "@wso2/ui-toolkit" import { useEffect, useMemo, useState } from "react" -import { getPropertyFromFormField, useFieldContext } from "@wso2/ballerina-side-panel" +import { getPropertyFromFormField, useFieldContext, InputMode } from "@wso2/ballerina-side-panel" import { ScrollableContainer } from "../Components/ScrollableContainer" import { HelperPaneIconType, getHelperPaneIcon } from "../utils/iconUtils" import { EmptyItemsPlaceHolder } from "../Components/EmptyItemsPlaceHolder" import { shouldShowNavigationArrow } from "../utils/types" +import { wrapInTemplateInterpolation } from "../utils/utils" import { HelperPaneListItem } from "../Components/HelperPaneListItem" import { useHelperPaneNavigation, BreadCrumbStep } from "../hooks/useHelperPaneNavigation" import { BreadcrumbNavigation } from "../Components/BreadcrumbNavigation" @@ -38,6 +39,7 @@ type InputsPageProps = { filteredCompletions: CompletionItem[]; currentValue: string; handleRetrieveCompletions: (value: string, property: ExpressionProperty, offset: number, triggerCharacter?: string) => Promise; + inputMode?: InputMode; } type InputItemProps = { @@ -64,8 +66,8 @@ const InputItem = ({ item, onItemSelect, onMoreIconClick }: InputItemProps) => { ); const endAction = showArrow ? ( - ) : undefined; @@ -81,44 +83,55 @@ const InputItem = ({ item, onItemSelect, onMoreIconClick }: InputItemProps) => { }; export const Inputs = (props: InputsPageProps) => { - const { targetLineRange, onChange, filteredCompletions, currentValue, handleRetrieveCompletions } = props; + const { targetLineRange, onChange, filteredCompletions, currentValue, handleRetrieveCompletions, inputMode } = props; const [searchValue, setSearchValue] = useState(""); const [isLoading, setIsLoading] = useState(true); const [showContent, setShowContent] = useState(false); - const { breadCrumbSteps, navigateToNext, navigateToBreadcrumb, isAtRoot } = useHelperPaneNavigation("Inputs"); + const { breadCrumbSteps, navigateToNext, navigateToBreadcrumb, isAtRoot, getCurrentNavigationPath } = useHelperPaneNavigation("Inputs"); const { field, triggerCharacters } = useFieldContext(); + // Use navigation path for completions instead of currentValue + const navigationPath = useMemo(() => getCurrentNavigationPath(), [breadCrumbSteps]); + const completionContext = useMemo(() => + navigationPath ? navigationPath + '.' : currentValue, + [navigationPath, currentValue] + ); + useEffect(() => { setIsLoading(true); const triggerCharacter = - currentValue.length > 0 - ? triggerCharacters.find((char) => currentValue[currentValue.length - 1] === char) + completionContext.length > 0 + ? triggerCharacters.find((char) => completionContext[completionContext.length - 1] === char) : undefined; // Only apply minimum loading time if we don't have any completions yet const shouldShowMinLoader = filteredCompletions.length === 0 && !showContent; const minLoadingTime = shouldShowMinLoader ? new Promise(resolve => setTimeout(resolve, 500)) : Promise.resolve(); + // When navigating, use length as offset to position cursor after the dot + // When at root, use 0 to get all completions + const offset = navigationPath ? completionContext.length : 0; + Promise.all([ - handleRetrieveCompletions(currentValue, getPropertyFromFormField(field), 0, triggerCharacter), + handleRetrieveCompletions(completionContext, getPropertyFromFormField(field), offset, triggerCharacter), minLoadingTime ]).finally(() => { setIsLoading(false); setShowContent(true); }); - }, [targetLineRange]) + }, [targetLineRange, breadCrumbSteps, completionContext]) const dropdownItems = useMemo(() => { // If we're at the root level, only show parameters if (isAtRoot()) { return filteredCompletions.filter( (completion) => - completion.kind === "variable" && - completion.labelDetails?.description?.includes("Parameter") + completion.kind === "variable" && + completion.labelDetails?.description?.includes("Parameter") ); } - + // If we're navigating inside an object, show all fields and variables return filteredCompletions.filter( (completion) => @@ -139,15 +152,19 @@ export const Inputs = (props: InputsPageProps) => { }; const handleItemSelect = (value: string) => { - onChange(value, false); + // Build full path from navigation + const fullPath = navigationPath ? `${navigationPath}.${value}` : value; + // Wrap in template interpolation if in template mode + const wrappedPath = wrapInTemplateInterpolation(fullPath, inputMode); + onChange(wrappedPath, false); } const handleInputsMoreIconClick = (value: string) => { - navigateToNext(value, currentValue, onChange); + navigateToNext(value, navigationPath); } const handleBreadCrumbItemClicked = (step: BreadCrumbStep) => { - navigateToBreadcrumb(step, onChange); + navigateToBreadcrumb(step); } const ExpandableListItems = () => { diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Variables.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Variables.tsx index f022615d2af..e115980586d 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Variables.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/Variables.tsx @@ -22,7 +22,7 @@ import { useRpcContext } from "@wso2/ballerina-rpc-client" import { DataMapperDisplayMode, ExpressionProperty, FlowNode, LineRange, RecordTypeField } from "@wso2/ballerina-core" import { Codicon, CompletionItem, Divider, HelperPaneCustom, SearchBox, ThemeColors, Tooltip, Typography } from "@wso2/ui-toolkit" import { useEffect, useMemo, useRef, useState } from "react" -import { getPropertyFromFormField, useFieldContext } from "@wso2/ballerina-side-panel" +import { getPropertyFromFormField, useFieldContext, InputMode } from "@wso2/ballerina-side-panel" import FooterButtons from "../Components/FooterButtons" import { FormGenerator } from "../../Forms/FormGenerator" import { ScrollableContainer } from "../Components/ScrollableContainer" @@ -32,6 +32,7 @@ import { POPUP_IDS, useModalStack } from "../../../../Context" import { HelperPaneIconType, getHelperPaneIcon } from "../utils/iconUtils" import { EmptyItemsPlaceHolder } from "../Components/EmptyItemsPlaceHolder" import { shouldShowNavigationArrow } from "../utils/types" +import { wrapInTemplateInterpolation } from "../utils/utils" import { HelperPaneListItem } from "../Components/HelperPaneListItem" import { useHelperPaneNavigation, BreadCrumbStep } from "../hooks/useHelperPaneNavigation" import { BreadcrumbNavigation } from "../Components/BreadcrumbNavigation" @@ -50,17 +51,18 @@ type VariablesPageProps = { isInModal?: boolean; handleRetrieveCompletions: (value: string, property: ExpressionProperty, offset: number, triggerCharacter?: string) => Promise; onClose?: () => void; + inputMode?: InputMode; } -type VariableItemProps = { +export type VariableItemProps = { item: CompletionItem; - onItemSelect: (value: string) => void; + onItemSelect: (value: string, item: CompletionItem) => void; onMoreIconClick: (value: string) => void; + hideArrow?: boolean; } -const VariableItem = ({ item, onItemSelect, onMoreIconClick }: VariableItemProps) => { - const showArrow = shouldShowNavigationArrow(item); - +export const VariableItem = ({ item, onItemSelect, onMoreIconClick, hideArrow }: VariableItemProps) => { + const showArrow = shouldShowNavigationArrow(item) && !hideArrow; const mainContent = ( <> {getHelperPaneIcon(HelperPaneIconType.VARIABLE)} @@ -76,14 +78,14 @@ const VariableItem = ({ item, onItemSelect, onMoreIconClick }: VariableItemProps ); const endAction = showArrow ? ( - ) : undefined; return ( onItemSelect(item.label)} + onClick={() => onItemSelect(item.label, item)} endAction={endAction} onClickEndAction={() => onMoreIconClick(item.label)} > @@ -93,18 +95,28 @@ const VariableItem = ({ item, onItemSelect, onMoreIconClick }: VariableItemProps }; export const Variables = (props: VariablesPageProps) => { - const { fileName, targetLineRange, onChange, onClose, handleOnFormSubmit, selectedType, filteredCompletions, currentValue, isInModal, handleRetrieveCompletions } = props; + const { fileName, targetLineRange, onChange, onClose, handleOnFormSubmit, selectedType, filteredCompletions, currentValue, isInModal, handleRetrieveCompletions, inputMode } = props; const [searchValue, setSearchValue] = useState(""); const { rpcClient } = useRpcContext(); const [isLoading, setIsLoading] = useState(true); const [showContent, setShowContent] = useState(false); const newNodeNameRef = useRef(""); const [projectPathUri, setProjectPathUri] = useState(); - const { breadCrumbSteps, navigateToNext, navigateToBreadcrumb, isAtRoot } = useHelperPaneNavigation("Variables"); + const { breadCrumbSteps, navigateToNext, navigateToBreadcrumb, isAtRoot, getCurrentNavigationPath } = useHelperPaneNavigation("Variables"); const { addModal, closeModal } = useModalStack() const { field, triggerCharacters } = useFieldContext(); + // Use navigation path for completions instead of currentValue + const navigationPath = useMemo(() => { + const path = getCurrentNavigationPath(); + return path; + }, [breadCrumbSteps]); + const completionContext = useMemo(() => { + const context = navigationPath ? navigationPath + '.' : currentValue; + return context; + }, [navigationPath, currentValue]); + useEffect(() => { getProjectInfo() }, []); @@ -112,23 +124,27 @@ export const Variables = (props: VariablesPageProps) => { useEffect(() => { setIsLoading(true); const triggerCharacter = - currentValue.length > 0 - ? triggerCharacters.find((char) => currentValue[currentValue.length - 1] === char) + completionContext.length > 0 + ? triggerCharacters.find((char) => completionContext[completionContext.length - 1] === char) : undefined; // Only apply minimum loading time if we don't have any completions yet const shouldShowMinLoader = filteredCompletions.length === 0 && !showContent; const minLoadingTime = shouldShowMinLoader ? new Promise(resolve => setTimeout(resolve, 500)) : Promise.resolve(); + // When navigating, use length as offset to position cursor after the dot + // When at root, use 0 to get all completions + const offset = navigationPath ? completionContext.length : 0; + Promise.all([ - handleRetrieveCompletions(currentValue, getPropertyFromFormField(field), 0, triggerCharacter), + handleRetrieveCompletions(completionContext, getPropertyFromFormField(field), offset, triggerCharacter), minLoadingTime ]).finally(() => { setIsLoading(false); setShowContent(true); }); - }, [targetLineRange]) + }, [targetLineRange, breadCrumbSteps, completionContext]) const getProjectInfo = async () => { const visualizerContext = await rpcClient.getVisualizerLocation(); @@ -157,12 +173,12 @@ export const Variables = (props: VariablesPageProps) => { const dropdownItems = useMemo(() => { const excludedDescriptions = ["Configurable", "Parameter", "Listener", "Client"]; - + return filteredCompletions.filter( (completion) => (completion.kind === "field" || completion.kind === "variable") && completion.label !== "self" && - !excludedDescriptions.some(desc => + !excludedDescriptions.some(desc => completion.labelDetails?.description?.includes(desc) ) ); @@ -179,8 +195,12 @@ export const Variables = (props: VariablesPageProps) => { setSearchValue(searchText); }; - const handleItemSelect = (value: string) => { - onChange(value, false); + const handleItemSelect = (value: string, _item?: CompletionItem) => { + // Build full path from navigation + const fullPath = navigationPath ? `${navigationPath}.${value}` : value; + // Wrap in template interpolation if in template mode + const wrappedPath = wrapInTemplateInterpolation(fullPath, inputMode); + onChange(wrappedPath, false); } const handleAddNewVariable = () => { @@ -200,11 +220,11 @@ export const Variables = (props: VariablesPageProps) => { onClose && onClose(); } const handleVariablesMoreIconClick = (value: string) => { - navigateToNext(value, currentValue, onChange); + navigateToNext(value, navigationPath); } const handleBreadCrumbItemClicked = (step: BreadCrumbStep) => { - navigateToBreadcrumb(step, onChange); + navigateToBreadcrumb(step); } const ExpandableListItems = () => { diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/hooks/useHelperPaneNavigation.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/hooks/useHelperPaneNavigation.tsx index 0b4083ade28..7ebd8753e27 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/hooks/useHelperPaneNavigation.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/hooks/useHelperPaneNavigation.tsx @@ -29,18 +29,16 @@ export const useHelperPaneNavigation = (initialLabel: string) => { replaceText: "" }]); - const navigateToNext = (value: string, currentValue: string, onChange: (value: string, isRecordConfigureChange: boolean, shouldKeepHelper?: boolean) => void) => { + const navigateToNext = (value: string, currentValue: string) => { + const separator = currentValue ? '.' : ''; const newBreadCrumSteps = [...breadCrumbSteps, { label: value, - replaceText: currentValue + value + replaceText: currentValue + separator + value }]; setBreadCrumbSteps(newBreadCrumSteps); - onChange(value + '.', false, true); }; - const navigateToBreadcrumb = (step: BreadCrumbStep, onChange: (value: string, isRecordConfigureChange: boolean, shouldKeepHelper?: boolean) => void) => { - const replaceText = step.replaceText === '' ? step.replaceText : step.replaceText + '.'; - onChange(replaceText, true); + const navigateToBreadcrumb = (step: BreadCrumbStep) => { const index = breadCrumbSteps.findIndex(item => item.label === step.label); const newSteps = index !== -1 ? breadCrumbSteps.slice(0, index + 1) : breadCrumbSteps; setBreadCrumbSteps(newSteps); @@ -53,11 +51,14 @@ export const useHelperPaneNavigation = (initialLabel: string) => { return breadCrumbSteps[breadCrumbSteps.length - 1].replaceText; }; + const getCurrentNavigationPath = getCurrentPath; + return { breadCrumbSteps, navigateToNext, navigateToBreadcrumb, isAtRoot, - getCurrentPath + getCurrentPath, + getCurrentNavigationPath }; }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/index.tsx index e455f104bb9..47965f6736c 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/index.tsx @@ -21,6 +21,8 @@ import { RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { ExpandableList } from './Components/ExpandableList'; import { Variables } from './Views/Variables'; import { Inputs } from './Views/Inputs'; +import { Documents } from './Views/Documents'; +import { DocumentConfig } from './Views/DocumentConfig'; import { CompletionInsertText, DataMapperDisplayMode, ExpressionProperty, FlowNode, LineRange, RecordTypeField } from '@wso2/ballerina-core'; import { CompletionItem, FormExpressionEditorRef, HelperPaneCustom, HelperPaneHeight, Typography } from '@wso2/ui-toolkit'; import { SlidingPane, SlidingPaneHeader, SlidingPaneNavContainer, SlidingWindow } from '@wso2/ui-toolkit'; @@ -34,8 +36,9 @@ import { POPUP_IDS, useModalStack } from '../../../Context'; import { getDefaultValue } from './utils/types'; import { EXPR_ICON_WIDTH } from '@wso2/ui-toolkit'; import { HelperPaneIconType, getHelperPaneIcon } from './utils/iconUtils'; -import { HelperpaneOnChangeOptions } from '@wso2/ballerina-side-panel'; +import { HelperpaneOnChangeOptions, InputMode } from '@wso2/ballerina-side-panel'; +const AI_PROMPT_TYPE = "ai:Prompt"; export type ValueCreationOption = { typeCheck: string | null; @@ -66,6 +69,7 @@ export type HelperPaneNewProps = { forcedValueTypeConstraint?: string; handleRetrieveCompletions: (value: string, property: ExpressionProperty, offset: number, triggerCharacter?: string) => Promise; handleValueTypeConstChange: (valueTypeConstraint: string) => void; + inputMode?: InputMode; }; const TitleContainer = styled.div` @@ -91,10 +95,13 @@ const HelperPaneNewEl = ({ valueTypeConstraint, handleRetrieveCompletions, forcedValueTypeConstraint, - handleValueTypeConstChange + handleValueTypeConstChange, + inputMode }: HelperPaneNewProps) => { const [selectedItem, setSelectedItem] = useState(); - const currentMenuItemCount = valueTypeConstraint ? 5 : 4 + const currentMenuItemCount = valueTypeConstraint ? + (forcedValueTypeConstraint?.includes(AI_PROMPT_TYPE) ? 6 : 5) : + (forcedValueTypeConstraint?.includes(AI_PROMPT_TYPE) ? 5 : 4) const { addModal } = useModalStack() @@ -299,7 +306,7 @@ const HelperPaneNewEl = ({ Variables - + menuItemRefs.current[3] = el} to="CONFIGURABLES" @@ -324,6 +331,19 @@ const HelperPaneNewEl = ({ + {forcedValueTypeConstraint?.includes(AI_PROMPT_TYPE) && ( + menuItemRefs.current[5] = el} + to="DOCUMENTS" + > + + {getHelperPaneIcon(HelperPaneIconType.DOCUMENT)} + + Documents + + + + )}
@@ -347,6 +367,7 @@ const HelperPaneNewEl = ({ isInModal={isInModal} handleRetrieveCompletions={handleRetrieveCompletions} onClose={onClose} + inputMode={inputMode} /> @@ -363,6 +384,7 @@ const HelperPaneNewEl = ({ filteredCompletions={filteredCompletions} currentValue={currentValue} handleRetrieveCompletions={handleRetrieveCompletions} + inputMode={inputMode} /> @@ -390,7 +412,8 @@ const HelperPaneNewEl = ({ onClose={onClose} onChange={handleChange} updateImports={updateImports} - selectedType={selectedType} /> + selectedType={selectedType} + inputMode={inputMode} /> @@ -404,6 +427,31 @@ const HelperPaneNewEl = ({ targetLineRange={targetLineRange} isInModal={isInModal} onClose={onClose} + inputMode={inputMode} + /> + + + {/* Documents Page */} + + + Documents + + + + + {/* Single Document Configuration Page - handles all document types */} + + + Documents + + @@ -481,6 +529,7 @@ export const getHelperPaneNew = (props: HelperPaneNewProps) => { handleRetrieveCompletions={props.handleRetrieveCompletions} forcedValueTypeConstraint={forcedValueTypeConstraint} handleValueTypeConstChange={handleValueTypeConstChange} + inputMode={props.inputMode} /> ); }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/iconUtils.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/iconUtils.tsx index 04bce692ddd..a3e22fcb298 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/iconUtils.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/iconUtils.tsx @@ -25,6 +25,7 @@ export enum HelperPaneIconType { INPUT = "input", CONFIGURABLE = "configurable", VALUE = "value", + DOCUMENT = "document", } const ICON_NAME_MAP: Record = { @@ -33,6 +34,7 @@ const ICON_NAME_MAP: Record = { [HelperPaneIconType.INPUT]: "bi-input", [HelperPaneIconType.CONFIGURABLE]: "bi-settings", [HelperPaneIconType.VALUE]: "bi-code", + [HelperPaneIconType.DOCUMENT]: "bi-attach-file", }; export const getHelperPaneIcon = ( diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/types.ts b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/types.ts index 5f8989af4a0..09fb8e09369 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/types.ts +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/types.ts @@ -32,14 +32,14 @@ export const DEFAULT_VALUE_MAP: Record = { export const getDefaultValue = (type: string) => { //TODO: handle this using API - return DEFAULT_VALUE_MAP[type] || ""; + return DEFAULT_VALUE_MAP[type] || ""; } export const isPrimitiveType = (typeDetail: string): boolean => { if (!typeDetail) return true; - + const cleanType = typeDetail.trim().toLowerCase(); - + const primitiveTypes = [ 'string', 'int', @@ -56,37 +56,37 @@ export const isPrimitiveType = (typeDetail: string): boolean => { '()', 'error' ]; - + // Check if it's a direct primitive type if (primitiveTypes.includes(cleanType)) { return true; } - + // Check if it's an array of primitive types (e.g., "string[]", "int[]") const arrayMatch = cleanType.match(/^(\w+)\[\]$/); if (arrayMatch) { const baseType = arrayMatch[1]; return primitiveTypes.includes(baseType); } - + // Check if it's a map of primitive types (e.g., "map", "map") const mapMatch = cleanType.match(/^map<(\w+)>$/); if (mapMatch) { const valueType = mapMatch[1]; return primitiveTypes.includes(valueType); } - + // Check if it's a union of primitive types (e.g., "string|int", "int?") if (cleanType.includes('|') || cleanType.endsWith('?')) { const unionTypes = cleanType.replace('?', '|nil').split('|'); return unionTypes.every(type => primitiveTypes.includes(type.trim())); } - + return false; }; // Determines if a completion item should show the navigation arrow export const shouldShowNavigationArrow = (item: CompletionItem): boolean => { const typeDetail = item?.labelDetails?.detail || item?.description; - return !isPrimitiveType(typeDetail); + return !isPrimitiveType(typeDetail) || item?.labelDetails?.description === "Record"; }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/utils.ts b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/utils.ts new file mode 100644 index 00000000000..e65fd742a3b --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/utils.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { InputMode } from "@wso2/ballerina-side-panel"; + +// Wraps a value in template interpolation syntax ${} if in template mode +export const wrapInTemplateInterpolation = (value: string, inputMode?: InputMode): string => { + return inputMode === InputMode.TEMPLATE ? `\${${value}}` : value; +}; diff --git a/workspaces/common-libs/font-wso2-vscode/src/icons/bi-attach-file.svg b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-attach-file.svg new file mode 100644 index 00000000000..4d2f8a2a1e0 --- /dev/null +++ b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-attach-file.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/workspaces/common-libs/font-wso2-vscode/src/icons/bi-audio.svg b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-audio.svg new file mode 100644 index 00000000000..3462890a0b5 --- /dev/null +++ b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-audio.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/workspaces/common-libs/font-wso2-vscode/src/icons/bi-image.svg b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-image.svg new file mode 100644 index 00000000000..7a705374988 --- /dev/null +++ b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-image.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/workspaces/common-libs/ui-toolkit/src/components/Commons/ErrorBanner.tsx b/workspaces/common-libs/ui-toolkit/src/components/Commons/ErrorBanner.tsx index 850dd4ba1a7..0744d902f92 100644 --- a/workspaces/common-libs/ui-toolkit/src/components/Commons/ErrorBanner.tsx +++ b/workspaces/common-libs/ui-toolkit/src/components/Commons/ErrorBanner.tsx @@ -20,12 +20,13 @@ import { css, cx } from "@emotion/css"; import styled from "@emotion/styled"; import React from "react"; -const Container = styled.div` +const Container = styled.div<{ sx?: React.CSSProperties }>` align-items: center; display: flex; flex-direction: row; background-color: var(--vscode-toolbar-activeBackground); padding: 6px; + ${(props: { sx?: React.CSSProperties }) => props.sx && { ...props.sx }} `; const ErrorMsg = styled.div` @@ -42,11 +43,11 @@ export const ErrorIcon = cx(css` color: var(--vscode-errorForeground); `); -export function ErrorBanner(props: { id?: string, className?: string, errorMsg: string }) { - const { id, className, errorMsg } = props; +export function ErrorBanner(props: { id?: string, className?: string, errorMsg: string, sx?: React.CSSProperties }) { + const { id, className, errorMsg, sx } = props; return ( - + {errorMsg} diff --git a/workspaces/common-libs/ui-toolkit/src/components/ExpressionEditor/components/Common/SlidingPane/index.tsx b/workspaces/common-libs/ui-toolkit/src/components/ExpressionEditor/components/Common/SlidingPane/index.tsx index 7a965e065ce..213e6508ef2 100644 --- a/workspaces/common-libs/ui-toolkit/src/components/ExpressionEditor/components/Common/SlidingPane/index.tsx +++ b/workspaces/common-libs/ui-toolkit/src/components/ExpressionEditor/components/Common/SlidingPane/index.tsx @@ -35,8 +35,8 @@ const SlidingWindowContainer = styled.div` position: relative; width: 320px; max-height: 350px; - overflow-x: scroll; - overflow-y: scroll; + overflow-x: hidden; + overflow-y: hidden; padding: 0px; background-color: var(--vscode-dropdown-background); transition: height 0.3s ease-in-out; @@ -294,3 +294,5 @@ export const CopilotFooter = styled.div` align-items: center; gap: 8px; `; + +export { useSlidingPane };