diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5588c7c0fa..f44bc2d1ab 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -974,6 +974,18 @@ importers: ../../workspaces/ballerina/ballerina-side-panel: dependencies: + '@codemirror/autocomplete': + specifier: ~6.19.1 + version: 6.19.1 + '@codemirror/commands': + specifier: ~6.10.0 + version: 6.10.0 + '@codemirror/state': + specifier: ~6.5.2 + version: 6.5.2 + '@codemirror/view': + specifier: ~6.38.6 + version: 6.38.6 '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@18.2.0)(react@18.2.0) diff --git a/workspaces/ballerina/ballerina-side-panel/package.json b/workspaces/ballerina/ballerina-side-panel/package.json index 8fcaa618b8..04324c9a4d 100644 --- a/workspaces/ballerina/ballerina-side-panel/package.json +++ b/workspaces/ballerina/ballerina-side-panel/package.json @@ -28,7 +28,11 @@ "lodash": "~4.17.21", "react-hook-form": "7.56.4", "react-markdown": "~10.1.0", - "@github/markdown-toolbar-element": "^2.2.3" + "@github/markdown-toolbar-element": "^2.2.3", + "@codemirror/commands": "~6.10.0", + "@codemirror/state": "~6.5.2", + "@codemirror/view": "~6.38.6", + "@codemirror/autocomplete": "~6.19.1" }, "devDependencies": { "@storybook/react": "^6.5.16", 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 ee8954b149..8a980f79aa 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 @@ -19,7 +19,7 @@ import React from "react"; import styled from "@emotion/styled"; import { EditorModeExpressionProps } from "./types"; -import { ChipExpressionBaseComponent } from "../../MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent"; +import { ChipExpressionEditorComponent } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; const ExpressionContainer = styled.div` width: 100%; @@ -48,7 +48,7 @@ export const ExpressionMode: React.FC = ({ return ( - = ({ extractArgsFromFunction={extractArgsFromFunction} getHelperPane={getHelperPane} isInExpandedMode={true} + isExpandedVersion={true} /> ); 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 d9faf3adbb..aa5b6638fa 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx @@ -28,9 +28,9 @@ import { import { S } from './ExpressionEditor'; import TextModeEditor from './MultiModeExpressionEditor/TextExpressionEditor/TextModeEditor'; import { InputMode } from './MultiModeExpressionEditor/ChipExpressionEditor/types'; -import { ChipExpressionBaseComponent } from './MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent'; import { LineRange } from '@wso2/ballerina-core/lib/interfaces/common'; import { HelperpaneOnChangeOptions } from '../Form/types'; +import { ChipExpressionEditorComponent } from './MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor'; export interface ExpressionField { inputMode: InputMode; @@ -150,8 +150,9 @@ export const ExpressionField: React.FC = ({ } return ( - void; - onTokenClick?: (token: string) => void; - getHelperPane?: ( - value: string, - onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, - helperPaneHeight: HelperPaneHeight - ) => React.ReactNode; - completions: CompletionItem[]; - onChange: (updatedValue: string, updatedCursorPosition: number) => void; - value: string; - fileName?: string; - extractArgsFromFunction?: (value: string, cursorPosition: number) => Promise<{ - label: string; - args: string[]; - currentArgIndex: number; - documentation?: FnSignatureDocumentation; - }>; - targetLineRange?: LineRange; - onOpenExpandedMode?: () => void; - onRemove?: () => void; - isInExpandedMode?: boolean; - expressionHeight?: string | number; -} - -export const ChipExpressionBaseComponent = (props: ChipExpressionBaseComponentProps) => { - const [tokens, setTokens] = useState([]); - const [expressionModel, setExpressionModel] = useState(); - const [selectedCompletionItem, setSelectedCompletionItem] = useState(0); - const [isCompletionsOpen, setIsCompletionsOpen] = useState(false); - const [hasTypedSinceFocus, setHasTypedSinceFocus] = useState(false); - const [isAnyElementFocused, setIsAnyElementFocused] = useState(false); - const [chipClicked, setChipClicked] = useState(null); - const [isHelperPaneOpen, setIsHelperPaneOpen] = useState(false); - const [filteredCompletions, setFilteredCompletions] = useState(props.completions); - const [isLoading, setIsLoading] = useState(false); - - const fieldContainerRef = useRef(null); - const fetchedInitialTokensRef = useRef(false); - const pendingCursorPositionUpdateRef = useRef(0); - const pendingForceSetTokensRef = useRef(null); - const fetchnewTokensRef = useRef(true); - const focusedTextElementRef = useRef(null); - const scheduledCompletionFilterRef = useRef(false); - const helperButtonRef = useRef(null); - - const { expressionEditor } = useFormContext(); - const expressionEditorRpcManager = expressionEditor?.rpcManager; - - const memoizedExpressionModel = useMemo( - () => createExpressionModelFromTokens(props.value, tokens), - [props.value, tokens] - ); - - const fetchUpdatedFilteredTokens = useCallback(async (value: string): Promise => { - setIsLoading(true); - try { - const response = await expressionEditorRpcManager?.getExpressionTokens( - value, - props.fileName, - props.targetLineRange.startLine - ); - setIsLoading(false); - return response || []; - } catch (error) { - setIsLoading(false); - return []; - } - }, [expressionEditorRpcManager]); - - const getFnSignature = useCallback(async (value: string, cursorPosition: number) => { - if (props.extractArgsFromFunction) { - setIsLoading(true); - const fnSignature = await props.extractArgsFromFunction(value, cursorPosition); - setIsLoading(false); - if (fnSignature) { - return fnSignature - } - } - return undefined; - }, [props.extractArgsFromFunction]); - - const fetchInitialTokens = async (value: string) => { - let updatedTokens = tokens; - - if (pendingForceSetTokensRef.current) { - setTokens(pendingForceSetTokensRef.current); - updatedTokens = pendingForceSetTokensRef.current; - pendingForceSetTokensRef.current = null; - } - if (fetchnewTokensRef.current) { - const filteredTokens = await fetchUpdatedFilteredTokens(value); - setTokens(filteredTokens); - updatedTokens = filteredTokens; - fetchnewTokensRef.current = false; - } - - fetchedInitialTokensRef.current = true; - let exprModel; - - if (value === props.value && updatedTokens === tokens) { - exprModel = memoizedExpressionModel; - } else { - exprModel = createExpressionModelFromTokens(value, updatedTokens); - } - - if (pendingCursorPositionUpdateRef.current !== null) { - exprModel = setCursorPositionToExpressionModel(exprModel, pendingCursorPositionUpdateRef.current); - pendingCursorPositionUpdateRef.current = null; - } - - setExpressionModel(exprModel); - }; - - useEffect(() => { - if (props.value === undefined || props.value === null) return; - fetchInitialTokens(props.value); - }, [props.value]); - - useEffect(() => { - if (props.value === undefined || props.value === null) return; - fetchnewTokensRef.current = true; - fetchInitialTokens(props.value); - }, [props.isInExpandedMode]); - - useEffect(() => { - if (!scheduledCompletionFilterRef.current) return; - const newFilteredCompletions = filterCompletionsByPrefixAndType(props.completions, ''); - setFilteredCompletions(newFilteredCompletions); - scheduledCompletionFilterRef.current = false; - }, [props.completions]); - - const handleExpressionChange = async ( - updatedModel: ExpressionModel[], - cursorPosition: number, - lastTypedText?: string - ) => { - const updatedValue = getTextValueFromExpressionModel(updatedModel); - - if (lastTypedText === FOCUS_MARKER) { - setExpressionModel(updatedModel); - setChipClicked(null); - setIsHelperPaneOpen(true); - return; - } - - // Calculate cursor movement - const cursorPositionBeforeUpdate = getAbsoluteCaretPositionFromModel(expressionModel); - const cursorPositionAfterUpdate = getAbsoluteCaretPositionFromModel(updatedModel); - const cursorDelta = updatedValue.length - props.value.length; - - // Update tokens based on cursor movement - const previousFullText = getTextValueFromExpressionModel(expressionModel); - const updatedTokens = updateTokens(tokens, cursorPositionBeforeUpdate, cursorDelta, previousFullText); - - const shouldUpdateTokens = (!lastTypedText?.startsWith('#$') || lastTypedText === BACKSPACE_MARKER) - && JSON.stringify(updatedTokens) !== JSON.stringify(tokens); - - if (shouldUpdateTokens) { - pendingForceSetTokensRef.current = updatedTokens; - } - - const wordBeforeCursor = getWordBeforeCursor(updatedModel); - const valueBeforeCursor = updatedValue.substring(0, cursorPositionAfterUpdate); - - // Handle helper pane and completions visibility - handleHelperPaneVisibility(updatedValue, valueBeforeCursor, wordBeforeCursor); - - // Handle navigation keys - if (isNavigationKey(lastTypedText)) { - handleNavigationKey(cursorPosition, lastTypedText); - return; - } - - // Determine if we need to fetch new tokens - if (shouldFetchNewTokens(lastTypedText)) { - fetchnewTokensRef.current = true; - } - - // Update cursor and value - pendingCursorPositionUpdateRef.current = cursorPosition; - props.onChange(updatedValue, cursorPosition); - setHasTypedSinceFocus(true); - }; - - const handleHelperPaneVisibility = ( - updatedValue: string, - valueBeforeCursor: string, - wordBeforeCursor: string | null - ) => { - const trimmedValueBeforeCursor = valueBeforeCursor.trim(); - - if (trimmedValueBeforeCursor.endsWith('+') || trimmedValueBeforeCursor.endsWith(':')) { - setIsHelperPaneOpen(true); - return; - } - - if (valueBeforeCursor === '') { - setIsHelperPaneOpen(true); - setIsCompletionsOpen(false); - return; - } - if (valueBeforeCursor.endsWith('.')) { - scheduledCompletionFilterRef.current = true; - return; - } - if (!wordBeforeCursor || wordBeforeCursor.trim() === '') { - setIsHelperPaneOpen(false); - setIsCompletionsOpen(false); - return; - } - const newFilteredCompletions = filterCompletionsByPrefixAndType(props.completions, wordBeforeCursor); - setFilteredCompletions(newFilteredCompletions); - - if (newFilteredCompletions.length > 0) { - setIsHelperPaneOpen(false); - setIsCompletionsOpen(true); - } else { - setIsHelperPaneOpen(false); - setIsCompletionsOpen(false); - } - }; - - const isNavigationKey = (lastTypedText?: string): boolean => { - return lastTypedText === ARROW_LEFT_MARKER - || lastTypedText === ARROW_RIGHT_MARKER; - }; - - const handleNavigationKey = (cursorPosition: number, lastTypedText?: string) => { - pendingCursorPositionUpdateRef.current = cursorPosition; - fetchInitialTokens(props.value); - }; - - const shouldFetchNewTokens = (lastTypedText?: string): boolean => { - if (!lastTypedText || lastTypedText.length === 0) { - return false; - } - - const isSpecialKey = lastTypedText === BACKSPACE_MARKER - || lastTypedText === COMPLETIONS_MARKER - || lastTypedText === HELPER_MARKER - || lastTypedText === DELETE_MARKER; - - const endsWithTriggerChar = lastTypedText.endsWith('+') - || lastTypedText.endsWith(' ') - || lastTypedText.endsWith(','); - - return endsWithTriggerChar || isSpecialKey; - }; - - const expandFunctionSignature = useCallback(async (value: string): Promise => { - if (value.endsWith('()')) { - const signature = await getFnSignature(value, value.length - 1); - if (signature) { - const argsString = signature.args.map((_, index) => `$${index + 1}`).join(','); - return value.slice(0, -1) + argsString + ')'; - } - } - return value; - }, [getFnSignature]); - - const handleCompletionSelect = async (item: CompletionItem) => { - const itemCopy = { ...item }; - itemCopy.value = await expandFunctionSignature(item.value); - const absoluteCaretPosition = getAbsoluteCaretPosition(expressionModel); - const updatedExpressionModelInfo = updateExpressionModelWithCompletion(expressionModel, absoluteCaretPosition, itemCopy.value); - - if (updatedExpressionModelInfo) { - const { updatedModel, newCursorPosition } = updatedExpressionModelInfo; - handleExpressionChange(updatedModel, newCursorPosition, COMPLETIONS_MARKER); - } - setIsCompletionsOpen(false); - }; - - const handleHelperPaneValueChange = async (updatedValue: string, options?: HelperpaneOnChangeOptions) => { - let currentModel = expressionModel ? [...expressionModel] : []; - if (currentModel.length === 0) { - currentModel.push({ - id: "1", - value: "", - isToken: false, - startColumn: 0, - startLine: 0, - length: 0, - type: 'literal', - isFocused: false, - focusOffsetStart: 0, - focusOffsetEnd: 0 - }); - } - if (options?.replaceFullText) { - const updatedTokens = await fetchUpdatedFilteredTokens(updatedValue); - let exprModel = createExpressionModelFromTokens(updatedValue, updatedTokens); - handleExpressionChange(exprModel, updatedValue.length, HELPER_MARKER); - return; - } - let value = await expandFunctionSignature(updatedValue); - if ( - chipClicked && - (chipClicked.type !== 'parameter' || - chipClicked.length > 0) - ) { - let absoluteCaretPosition = 0; - for (let i = 0; i < currentModel?.length; i++) { - if (currentModel && currentModel[i].isFocused) { - absoluteCaretPosition += currentModel[i]?.focusOffsetStart || 0; - break; - } - absoluteCaretPosition += currentModel ? currentModel[i].value.length : 0; - } - const updatedExpressionModelInfo = updateExpressionModelWithHelperValue(currentModel, absoluteCaretPosition, value, true); - - if (updatedExpressionModelInfo) { - const { updatedModel, updatedValue, newCursorPosition } = updatedExpressionModelInfo; - - const textValue = getTextValueFromExpressionModel(updatedModel || []); - const updatedTokens = await fetchUpdatedFilteredTokens(textValue); - - let exprModel = createExpressionModelFromTokens(textValue, updatedTokens); - - // Map absolute position into new model and set focus flags - const mapped = mapAbsoluteToModel(exprModel, absoluteCaretPosition + value.length); - exprModel = setFocusInExpressionModel(exprModel, mapped, true); - setChipClicked(null); - handleExpressionChange(exprModel, newCursorPosition, HELPER_MARKER); - } - } - else { - let selectedElement = currentModel.find(el => el.isFocused); - let shouldReplaceEntireValue = false; - if (!selectedElement) { - selectedElement = currentModel[currentModel.length - 1]; - }; - let absoluteCaretPosition = getAbsoluteCaretPositionFromModel(currentModel); - if ( - selectedElement.focusOffsetStart !== undefined && - selectedElement.focusOffsetEnd !== undefined && - selectedElement.focusOffsetStart !== selectedElement.focusOffsetEnd - ) { - const newValue = selectedElement.value.substring(0, selectedElement.focusOffsetStart) + - selectedElement.value.substring(selectedElement.focusOffsetEnd); - - currentModel = currentModel.map(el => { - if (el === selectedElement) { - return { - ...el, - value: newValue, - length: newValue.length, - focusOffsetStart: selectedElement.focusOffsetStart, - focusOffsetEnd: selectedElement.focusOffsetStart, - isFocused: true - }; - } - return el; - }); - - let sumBeforeSelected = 0; - for (let i = 0; i < currentModel.length; i++) { - if (currentModel[i].isFocused) { - break; - } - sumBeforeSelected += currentModel[i].length; - } - absoluteCaretPosition = sumBeforeSelected + selectedElement.focusOffsetStart; - shouldReplaceEntireValue = false; - } - const updatedExpressionModelInfo = updateExpressionModelWithHelperValue(currentModel, absoluteCaretPosition, value, shouldReplaceEntireValue); - if (updatedExpressionModelInfo) { - const { updatedModel, newCursorPosition } = updatedExpressionModelInfo; - - const textValue = getTextValueFromExpressionModel(updatedModel || []); - const updatedTokens = await fetchUpdatedFilteredTokens(textValue); - - let exprModel = createExpressionModelFromTokens(textValue, updatedTokens); - - // Map absolute position into new model and set focus flags - const mapped = mapAbsoluteToModel(exprModel, absoluteCaretPosition + value.length); - exprModel = setFocusInExpressionModel(exprModel, mapped, true); - handleExpressionChange(exprModel, newCursorPosition, HELPER_MARKER); - } - } - if (options?.closeHelperPane) { - setIsHelperPaneOpen(false); - } - else { - setIsHelperPaneOpen(true); - } - }; - - const handleCompletionKeyDown = useCallback((e: React.KeyboardEvent) => { - if (!isCompletionsOpen || filteredCompletions.length === 0) return; - - handleCompletionNavigation( - e, - filteredCompletions.length, - selectedCompletionItem, - setSelectedCompletionItem, - handleCompletionSelect, - setIsCompletionsOpen, - filteredCompletions - ); - }, [isCompletionsOpen, selectedCompletionItem, filteredCompletions]); - - const handleHelperKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - setIsHelperPaneOpen(false); - } - }, []); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (isCompletionsOpen) { - handleCompletionKeyDown(e); - } - if (isHelperPaneOpen) { - handleHelperKeyDown(e); - } - }, [isCompletionsOpen, handleCompletionKeyDown, isHelperPaneOpen, handleHelperKeyDown]); - - useEffect(() => { - if (filteredCompletions.length === 0) { - setIsCompletionsOpen(false); - return; - } - if (isAnyElementFocused && hasTypedSinceFocus && !isHelperPaneOpen) { - setIsCompletionsOpen(true); - setSelectedCompletionItem(-1); - } else { - setIsCompletionsOpen(false); - } - }, [filteredCompletions, isAnyElementFocused, hasTypedSinceFocus]); - - const handleChipClick = useCallback((element: HTMLElement, value: string, type: string, id?: string) => { - const clickedChip = expressionModel?.find(model => model.id === id); - if (!clickedChip) return; - setChipClicked(clickedChip); - - const chipId = element.getAttribute(DATA_ELEMENT_ID_ATTRIBUTE); - if (chipId && expressionModel) { - const updatedExpressionModel = expressionModel.map(model => { - if (model.id === chipId) { - return { ...model, isFocused: true, focusOffsetStart: Math.max(model.length - 1, 0) }; - } - return { ...model, isFocused: false }; - }); - - setExpressionModel(updatedExpressionModel); - } - - setIsHelperPaneOpen(true); - }, [expressionModel]); - - const handleChipFocus = useCallback((element: HTMLElement, value: string, type: string, absoluteOffset?: number) => { - const chipId = element.getAttribute(DATA_ELEMENT_ID_ATTRIBUTE); - if (chipId && expressionModel) { - const updatedExpressionModel = expressionModel.map(model => { - if (model.id === chipId) { - return { ...model, isFocused: true, focusOffsetStart: 0, focusOffsetEnd: 0 }; - } - return { ...model, isFocused: false, focusOffsetStart: undefined, focusOffsetEnd: undefined }; - }); - setExpressionModel(updatedExpressionModel); - } - }, [expressionModel]); - - const handleChipBlur = useCallback(() => { - }, []); - - const toggleHelperPane = useCallback(() => { - setIsHelperPaneOpen(prev => !prev); - }, []); - - useEffect(() => { - }, [pendingCursorPositionUpdateRef.current, expressionModel]); - - const handleTextFocus = (e: React.FocusEvent) => { - focusedTextElementRef.current = e.currentTarget; - } - - return ( - <> - {props.isInExpandedMode && ( - - )} - - {!props.isInExpandedMode && } -
- { - setIsAnyElementFocused(focused); - if (!focused && expressionModel) { - const cleared = expressionModel.map(el => ({ ...el, isFocused: false, focusOffset: undefined })); - handleExpressionChange(cleared, getAbsoluteCaretPosition(cleared), FOCUS_MARKER); - } - }} - onKeyDown={handleKeyDown} - isCompletionsOpen={isCompletionsOpen} - completions={filteredCompletions} - selectedCompletionItem={selectedCompletionItem} - onCompletionSelect={handleCompletionSelect} - onCompletionHover={setSelectedCompletionItem} - onCloseCompletions={() => setIsCompletionsOpen(false)} - getHelperPane={props.getHelperPane} - isHelperPaneOpen={isHelperPaneOpen} - handleHelperPaneValueChange={handleHelperPaneValueChange} - onHelperPaneClose={() => setIsHelperPaneOpen(false)} - onToggleHelperPane={toggleHelperPane} - isInExpandedMode={props.isInExpandedMode} - onOpenExpandedMode={props.onOpenExpandedMode} - helperButtonRef={helperButtonRef} - > - - -
- {props.onRemove && ( - - )} -
- - ) -} 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 new file mode 100644 index 0000000000..0714d76175 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts @@ -0,0 +1,436 @@ +/** + * 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 { 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 { defaultKeymap, historyKeymap } from "@codemirror/commands"; +import { CompletionItem } from "@wso2/ui-toolkit"; +import { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; + +export type TokenStream = number[]; + +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) { + class ChipWidget extends WidgetType { + constructor(readonly text: string, readonly type: TokenType, readonly start: number, readonly end: number, readonly view: EditorView) { + 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.3)"; + let color = "white"; + switch (this.type) { + case 'variable': + case 'property': + backgroundColor = "rgba(0, 122, 204, 0.3)"; + color = "white"; + break; + case 'parameter': + backgroundColor = "#70c995"; + color = "#000000"; // Dark color for light background + break; + default: + backgroundColor = "rgba(0, 122, 204, 0.3)"; + color = "white"; + } + span.style.background = backgroundColor; + span.style.color = color; + span.style.borderRadius = "4px"; + span.style.padding = "2px 10px"; + span.style.margin = "2px 0px"; + span.style.display = "inline-block"; + span.style.cursor = "pointer"; + span.style.fontSize = "12px"; + span.style.minHeight = "20px"; + span.style.minWidth = "25px"; + span.style.transition = "all 0.2s ease"; + span.style.outline = "none"; + span.style.verticalAlign = "middle"; + span.style.userSelect = "none"; + // Add click handler to select the chip text + span.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + this.view.dispatch({ + selection: { anchor: this.start, head: this.end } + }); + this.view.focus(); + }); + + return span; + } + ignoreEvent() { + return false; + } + eq(other: ChipWidget) { + return other.text === this.text && other.start === this.start && other.end === this.end; + } + } + return Decoration.replace({ + widget: new ChipWidget(text, type, start, end, view), + inclusive: false, + block: false + }); +} + +export const chipTheme = EditorView.theme({ + "&": { + backgroundColor: "var(--vscode-input-background)" + }, + ".cm-content": { + caretColor: "#ffffff", + paddingRight: "40px" + }, + "&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor": { + borderLeftColor: "#ffffff" + } +}); + +export const completionTheme = EditorView.theme({ + ".cm-tooltip.cm-tooltip-autocomplete": { + backgroundColor: "#2d2d30", + border: "1px solid #454545", + borderRadius: "3px", + padding: "2px 0px", + maxHeight: "300px", + overflow: "auto", + animation: "fadeInUp 0.3s ease forwards", + }, + ".cm-tooltip.cm-tooltip-autocomplete > ul": { + fontFamily: "var(--vscode-font-family)", + fontSize: "13px", + listStyle: "none", + margin: "0", + padding: "0", + }, + ".cm-tooltip.cm-tooltip-autocomplete > ul > li": { + height: "25px", + display: "flex", + alignItems: "center", + padding: "0px 5px", + color: "var(--vscode-input-foreground, #ffffff)", + cursor: "pointer", + }, + ".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": { + backgroundColor: "var(--vscode-list-activeSelectionBackground, #094771)", + }, + ".cm-tooltip.cm-tooltip-autocomplete > ul > li:hover": { + backgroundColor: "#3e3e42", + }, + ".cm-completionLabel": { + flex: "1", + }, + ".cm-completionDetail": { + fontStyle: "italic", + color: "#858585", + fontSize: "12px", + }, +}); + +export const tokensChangeEffect = StateEffect.define(); +export const removeChipEffect = StateEffect.define(); // contains token ID + +export const tokenField = StateField.define({ + create() { + return []; + }, + update(oldTokens, tr) { + + oldTokens = oldTokens.map(token => ({ + ...token, + start: tr.changes.mapPos(token.start, 1), + end: tr.changes.mapPos(token.end, -1) + })); + + for (let effect of tr.effects) { + if (effect.is(tokensChangeEffect)) { + const tokenObjects = getParsedExpressionTokens(effect.value, tr.newDoc.toString()); + return tokenObjects; + } + if (effect.is(removeChipEffect)) { + const removingTokenId = effect.value; + return oldTokens.filter(token => token.id !== removingTokenId); + } + } + return oldTokens; + } +}); + +export const chipPlugin = ViewPlugin.fromClass( + class { + decorations: RangeSet; + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + update(update: ViewUpdate) { + const hasTokensChangeEffect = update.transactions.some(tr => + tr.effects.some(e => e.is(tokensChangeEffect)) + ); + const hasDocOrViewportChange = update.docChanged || update.viewportChanged; + if (hasDocOrViewportChange || hasTokensChangeEffect) { + this.decorations = this.buildDecorations(update.view); + } + } + 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)); + } + + return Decoration.set(widgets, true); + } + }, + { + decorations: v => v.decorations + } +); + +export const expressionEditorKeymap = [ + { + key: "Backspace", + run: (view) => { + const state = view.state; + const tokens = state.field(tokenField, false); + if (!tokens) return false; + + const cursor = state.selection.main.head; + + const affectedToken = tokens.find(token => token.start < cursor && token.end >= cursor); + + if (affectedToken) { + view.dispatch({ + effects: removeChipEffect.of(affectedToken.id), + changes: { from: affectedToken.start, to: affectedToken.end, insert: '' } + }); + return true; + } + return false; + } + }, + ...defaultKeymap, + ...historyKeymap +]; + +// this always returns the cursor position with correction for helper pane width overflow +// make sure all the dropdowns that use this handle has the same width +export const buildOnFocusListner = (onTrigger: (cursor: CursorInfo) => void) => { + const shouldOpenHelperPaneListner = EditorView.updateListener.of((update) => { + if (update.focusChanged) { + if (!update.view.hasFocus) { + return; + } + + const cursorPosition = update.view.state.selection.main; + const coords = update.view.coordsAtPos(cursorPosition.to); + + if (coords && coords.top && coords.left) { + const editorRect = update.view.dom.getBoundingClientRect(); + //+5 is to position a little be below the cursor + //otherwise it overlaps with the cursor + let relativeTop = coords.bottom - editorRect.top + 5; + let relativeLeft = coords.left - editorRect.left; + + const HELPER_PANE_WIDTH = 300; + const viewportWidth = window.innerWidth; + const absoluteLeft = coords.left; + const overflow = absoluteLeft + HELPER_PANE_WIDTH - viewportWidth; + + if (overflow > 0) { + relativeLeft -= overflow; + } + + onTrigger({ top: relativeTop, left: relativeLeft, position: cursorPosition }); + } + } + }); + return shouldOpenHelperPaneListner; +}; + +// this always returns the cursor position with correction for helper pane width overflow +// make sure all the dropdowns that use this handle has the same width +export const buildOnSelectionChange = (onTrigger: (cursor: CursorInfo) => void) => { + const selectionListener = EditorView.updateListener.of((update) => { + if (!update.selectionSet) return; + if (update.docChanged) return; + if (!update.view.hasFocus) return; + + if (update.transactions.some(tr => tr.annotation(ProgrammerticSelectionChange))) { + return; + } + + const cursorPosition = update.state.selection.main; + const coords = update.view.coordsAtPos(cursorPosition.to); + + if (coords && coords.top && coords.left) { + const editorRect = update.view.dom.getBoundingClientRect(); + //+5 is to position a little be below the cursor + //otherwise it overlaps with the cursor + let relativeTop = coords.bottom - editorRect.top + 5; + let relativeLeft = coords.left - editorRect.left; + + const HELPER_PANE_WIDTH = 300; + const viewportWidth = window.innerWidth; + const absoluteLeft = coords.left; + const overflow = absoluteLeft + HELPER_PANE_WIDTH - viewportWidth; + + if (overflow > 0) { + relativeLeft -= overflow; + } + + onTrigger({ top: relativeTop, left: relativeLeft, position: cursorPosition }); + } + }); + return selectionListener; +}; + +export const buildOnFocusOutListner = (onTrigger: () => void) => { + const shouldOpenHelperPaneListner = EditorView.updateListener.of((update) => { + if (update.focusChanged) { + if (update.view.hasFocus) return; + onTrigger(); + } + }); + return shouldOpenHelperPaneListner; +}; + +export const buildNeedTokenRefetchListner = (onTrigger: () => void) => { + const needTokenRefetchListner = EditorView.updateListener.of((update) => { + const userEvent = update.transactions[0]?.annotation(Transaction.userEvent); + if (update.docChanged && ( + userEvent === "input.type" || + userEvent === "input.paste" || + userEvent === "delete.backward" || + userEvent === "delete.forward" || + userEvent === "delete.cut" + )) { + update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { + const insertedText = inserted.toString(); + if (insertedText.endsWith(' ')) { + onTrigger(); + } + }); + } + }); + return needTokenRefetchListner; +} + +export const buildOnChangeListner = (onTrigeer: (newValue: string, cursor: CursorInfo) => void) => { + const onChangeListner = EditorView.updateListener.of((update) => { + const cursorPos = update.view.state.selection.main; + const coords = update.view.coordsAtPos(cursorPos.to); + + if (update.transactions.some(tr => tr.annotation(SyncDocValueWithPropValue))) { + return; + } + + if (!coords || coords.top === null || coords.left === null) { + throw new Error("Could not get cursor coordinates"); + } + if (update.docChanged) { + const editorRect = update.view.dom.getBoundingClientRect(); + //+5 is to position a little be below the cursor + //otherwise it overlaps with the cursor + let relativeTop = coords.bottom - editorRect.top + 5; + let relativeLeft = coords.left - editorRect.left; + + const HELPER_PANE_WIDTH = 300; + const viewportWidth = window.innerWidth; + const absoluteLeft = coords.left; + const overflow = absoluteLeft + HELPER_PANE_WIDTH - viewportWidth; + + if (overflow > 0) { + relativeLeft -= overflow; + } + + const newValue = update.view.state.doc.toString(); + const cursorInfo = { + top: relativeTop, + left: relativeLeft, + position: cursorPos + }; + onTrigeer(newValue, cursorInfo); + } + }); + return onChangeListner; +} + +export const buildCompletionSource = (getCompletions: () => CompletionItem[]) => { + return (context: CompletionContext): CompletionResult | null => { + const word = context.matchBefore(/\w*/); + if (!word || (word.from === word.to && !context.explicit)) { + return null; + } + + const textBeforeCursor = context.state.doc.toString().slice(0, context.pos); + const lastNonSpaceChar = textBeforeCursor.trimEnd().slice(-1); + + // Don't show completions for trigger characters + if (lastNonSpaceChar === '+' || lastNonSpaceChar === ':') { + return null; + } + + const completions = getCompletions(); + const prefix = word.text; + const filteredCompletions = filterCompletionsByPrefixAndType(completions, prefix); + + if (filteredCompletions.length === 0) { + return null; + } + + return { + from: word.from, + options: filteredCompletions.map(item => ({ + label: item.label, + type: item.kind || "variable", + detail: item.description, + apply: item.value, + })) + }; + }; +}; + +export const buildHelperPaneKeymap = (getIsHelperPaneOpen: () => boolean, onClose: () => void) => { + return [ + { + key: "Escape", + run: (_view) => { + if (!getIsHelperPaneOpen()) return false; + onClose(); + return true; + } + } + ]; +}; 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 new file mode 100644 index 0000000000..4b300ea0f4 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx @@ -0,0 +1,374 @@ +/** + * 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 { EditorState } from "@codemirror/state"; +import { EditorView, keymap } from "@codemirror/view"; +import React, { useEffect, useRef, useState } from "react"; +import { useFormContext } from "../../../../../context"; +import { + buildNeedTokenRefetchListner, + buildOnChangeListner, + chipPlugin, + chipTheme, + completionTheme, + tokenField, + tokensChangeEffect, + expressionEditorKeymap, + buildCompletionSource, + buildHelperPaneKeymap, + buildOnFocusListner, + CursorInfo, + buildOnFocusOutListner, + buildOnSelectionChange, + ProgrammerticSelectionChange, + SyncDocValueWithPropValue +} from "../CodeUtils"; +import { history } from "@codemirror/commands"; +import { autocompletion } from "@codemirror/autocomplete"; +import { FloatingButtonContainer, FloatingToggleButton, ChipEditorContainer } from "../styles"; +import { HelperpaneOnChangeOptions } from "../../../../Form/types"; +import { CompletionItem, FnSignatureDocumentation, HelperPaneHeight } from "@wso2/ui-toolkit"; +import { CloseHelperButton, ExpandButton, OpenHelperButton } from "./FloatingButtonIcons"; +import { LineRange } from "@wso2/ballerina-core"; +import FXButton from "./FxButton"; +import { HelperPaneToggleButton } from "./HelperPaneToggleButton"; +import { HelperPane } from "./HelperPane"; + +type HelperPaneState = { + isOpen: boolean; + top: number; + left: number; +} +export type ChipExpressionEditorComponentProps = { + onTokenRemove?: (token: string) => void; + onTokenClick?: (token: string) => void; + isExpandedVersion: boolean; + getHelperPane?: ( + value: string, + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, + helperPaneHeight: HelperPaneHeight + ) => React.ReactNode; + completions: CompletionItem[]; + onChange: (updatedValue: string, updatedCursorPosition: number) => void; + value: string; + fileName?: string; + extractArgsFromFunction?: (value: string, cursorPosition: number) => Promise<{ + label: string; + args: string[]; + currentArgIndex: number; + documentation?: FnSignatureDocumentation; + }>; + targetLineRange: LineRange; + onOpenExpandedMode?: () => void; + onRemove?: () => void; + isInExpandedMode?: boolean; + + // TODO: change this prop to pass a style object instead of just height + // to allow more flexibility in styling + expressionHeight?: string | number; +} + +export const ChipExpressionEditorComponent = (props: ChipExpressionEditorComponentProps) => { + const [helperPaneState, setHelperPaneState] = useState({ isOpen: false, top: 0, left: 0 }); + + const editorRef = useRef(null); + const helperPaneRef = useRef(null); + const fieldContainerRef = useRef(null); + const viewRef = useRef(null); + const [isTokenUpdateScheduled, setIsTokenUpdateScheduled] = useState(true); + const completionsRef = useRef(props.completions); + const helperPaneToggleButtonRef = useRef(null); + + const { expressionEditor } = useFormContext(); + const expressionEditorRpcManager = expressionEditor?.rpcManager; + + const needTokenRefetchListner = buildNeedTokenRefetchListner(() => { + setIsTokenUpdateScheduled(true); + }); + + const handleChangeListner = buildOnChangeListner((newValue, cursor) => { + props.onChange(newValue, cursor.position.to); + const textBeforeCursor = newValue.slice(0, cursor.position.to); + const lastNonSpaceChar = textBeforeCursor.trimEnd().slice(-1); + const isTrigger = lastNonSpaceChar === '+' || lastNonSpaceChar === ':'; + const isRangeSelection = cursor.position.to !== cursor.position.from; + + if (newValue === '' || isTrigger || isRangeSelection) { + setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); + } else { + setHelperPaneState({ isOpen: false, top: 0, left: 0 }); + } + }); + + const handleFocusListner = buildOnFocusListner((cursor: CursorInfo) => { + setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); + }); + + const handleSelectionChange = buildOnSelectionChange((cursor: CursorInfo) => { + setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); + }); + + const handleFocusOutListner = buildOnFocusOutListner(() => { + setIsTokenUpdateScheduled(true); + }); + + const completionSource = buildCompletionSource(() => completionsRef.current); + + const helperPaneKeymap = buildHelperPaneKeymap(() => helperPaneState.isOpen, () => { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + }); + + const onHelperItemSelect = async (value: string, options: HelperpaneOnChangeOptions) => { + 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; + + let finalValue = newValue; + let cursorPosition = from + newValue.length; + + // HACK: this should be handled properly with completion items template + // 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('()')) { + if (props.extractArgsFromFunction) { + try { + const cursorPositionForExtraction = from + newValue.length - 1; + const fnSignature = await props.extractArgsFromFunction(newValue, 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; + } + } catch (error) { + console.warn('Failed to extract function arguments:', error); + } + } + } + + view.dispatch({ + changes: { from, to, insert: finalValue }, + selection: { anchor: cursorPosition } + }); + if (options.closeHelperPane) { + setIsTokenUpdateScheduled(true); + } + setHelperPaneState(prev => ({ ...prev, isOpen: !options.closeHelperPane })); + } + + const handleHelperPaneManualToggle = () => { + if ( + !helperPaneToggleButtonRef?.current || + !editorRef?.current + ) return; + const buttonRect = helperPaneToggleButtonRef.current.getBoundingClientRect(); + const editorRect = editorRef.current?.getBoundingClientRect(); + let top = buttonRect.bottom - editorRect.top; + let left = buttonRect.left - editorRect.left; + + // Add overflow correction for window boundaries + const HELPER_PANE_WIDTH = 300; + const viewportWidth = window.innerWidth; + const absoluteLeft = buttonRect.left; + const overflow = absoluteLeft + HELPER_PANE_WIDTH - viewportWidth; + + if (overflow > 0) { + left -= overflow; + } + + setHelperPaneState(prev => ({ + ...prev, + top, + left, + isOpen: !prev.isOpen + })); + } + + // Determine height based on expressionHeight prop, or fall back to default behavior + const getHeightValue = (height: string | number) => { + return typeof height === 'number' + ? `${height}px` + : height; + }; + + useEffect(() => { + if (!editorRef.current) return; + const startState = EditorState.create({ + doc: props.value ?? "", + extensions: [ + history(), + keymap.of([...helperPaneKeymap, ...expressionEditorKeymap]), + autocompletion({ + override: [completionSource], + activateOnTyping: true, + closeOnBlur: true + }), + chipPlugin, + tokenField, + chipTheme, + completionTheme, + EditorView.lineWrapping, + needTokenRefetchListner, + handleChangeListner, + handleFocusListner, + handleFocusOutListner, + handleSelectionChange, + ...(props.isInExpandedMode + ? [EditorView.theme({ + "&": { height: "100%" }, + ".cm-scroller": { overflow: "auto" } + })] + : props.expressionHeight + ? [EditorView.theme({ + "&": { height: getHeightValue(props.expressionHeight) }, + ".cm-scroller": { overflow: "auto" } + })] + : []) + ] + }); + const view = new EditorView({ + state: startState, + parent: editorRef.current + }); + viewRef.current = view; + return () => { + view.destroy(); + }; + }, []); + + useEffect(() => { + if (!props.value || !viewRef.current) return; + const updateEditorState = async () => { + const currentDoc = viewRef.current!.state.doc.toString(); + const isExternalUpdate = props.value !== currentDoc; + + if (!isTokenUpdateScheduled && !isExternalUpdate) return; + + const currentSelection = viewRef.current!.state.selection.main; + + const tokenStream = await expressionEditorRpcManager?.getExpressionTokens( + props.value, + props.fileName, + props.targetLineRange.startLine + ); + setIsTokenUpdateScheduled(false); + if (tokenStream) { + viewRef.current!.dispatch({ + effects: tokensChangeEffect.of(tokenStream), + changes: { from: 0, to: viewRef.current!.state.doc.length, insert: props.value }, + selection: { anchor: currentSelection.anchor, head: currentSelection.head }, + ...{annotations: isExternalUpdate ? [SyncDocValueWithPropValue.of(true)] : []} + }); + } + }; + updateEditorState(); + }, [props.value, props.fileName, props.targetLineRange.startLine, isTokenUpdateScheduled]); + + + // this keeps completions ref updated + // just don't touch this. + useEffect(() => { + completionsRef.current = props.completions; + }, [props.completions]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!helperPaneState.isOpen) return; + + const target = event.target as Element; + const isClickInsideEditor = editorRef.current?.contains(target); + const isClickInsideHelperPane = helperPaneRef.current?.contains(target); + + if (!isClickInsideEditor && !isClickInsideHelperPane) { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + viewRef.current?.dispatch({ + selection: { anchor: 0 }, + annotations: ProgrammerticSelectionChange.of(true) + }); + viewRef.current?.dom.blur(); + } + }; + if (helperPaneState.isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [helperPaneState.isOpen]); + + return ( + <> + {props.isExpandedVersion && ( + + )} + + {!props.isInExpandedMode && } +
+
+ {helperPaneState.isOpen && + + } + + {!props.isExpandedVersion && + + {helperPaneState.isOpen ? : } + } + {props.onOpenExpandedMode && !props.isInExpandedMode && ( + + + + )} + +
+ + + + ); +} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPane.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPane.tsx new file mode 100644 index 0000000000..cbf582df56 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPane.tsx @@ -0,0 +1,54 @@ +/** + * 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 { ContextMenuContainer } from "../styles"; +import { HelperpaneOnChangeOptions } from "../../../../Form/types"; +import { HelperPaneHeight } from "@wso2/ui-toolkit"; + +export type HelperPaneProps = { + top: number; + left: number; + getHelperPane: ( + value: string, + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, + helperPaneHeight: HelperPaneHeight + ) => React.ReactNode; + value: string; + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void; +} + +export const HelperPane = React.forwardRef((props, ref) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + {props.getHelperPane( + props.value, + props.onChange, + "3/4" + )} + + ); +}); \ No newline at end of file diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx new file mode 100644 index 0000000000..1efb994b99 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx @@ -0,0 +1,64 @@ +/** + * 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 { Codicon } from "@wso2/ui-toolkit"; + +interface HelperPaneToggleButtonProps { + isOpen: boolean; + onClick: () => void; +} + +export const HelperPaneToggleButton = React.forwardRef(({ + isOpen, + onClick +}, ref) => { + + return ( + + ); +}); \ No newline at end of file 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 75157bc8fc..082e385936 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 @@ -87,7 +87,7 @@ export const ContextMenuContainer = styled.div<{ top: number; left: number }>` background-color: ${ThemeColors.SURFACE_CONTAINER}; border-radius: 4px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); - z-index: 1000; + z-index: 1600; min-width: 120px; width: ${COMPLETIONS_WIDTH}px; overflow: hidden; 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 506a24ef49..54daddaf56 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 @@ -19,6 +19,7 @@ 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"; const TOKEN_LINE_OFFSET_INDEX = 0; const TOKEN_START_CHAR_OFFSET_INDEX = 1; @@ -195,7 +196,7 @@ export const createExpressionModelFromTokens = ( if (!value) return []; if (!tokens || tokens.length === 0) { return [{ - ...expressionModelInitValue, value: value, length: value.length, + ...expressionModelInitValue, value: value, length: value.length, }]; } @@ -307,12 +308,13 @@ export const createExpressionModelFromTokens = ( return expressionModel; }; -const getTokenTypeFromIndex = (index: number): string => { - const tokenTypes: { [key: number]: string } = { + + +const getTokenTypeFromIndex = (index: number): TokenType => { + const tokenTypes: { [key: number]: TokenType } = { 0: 'variable', - 1: 'function', + 1: 'property', 2: 'parameter', - 3: 'property', }; return tokenTypes[index] || 'variable'; }; @@ -351,9 +353,9 @@ export const getSelectionOffsets = (el: HTMLElement): { start: number; end: numb 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 }; @@ -365,13 +367,13 @@ export const getSelectionOffsets = (el: HTMLElement): { start: number; end: numb 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; @@ -379,7 +381,7 @@ export const getSelectionOffsets = (el: HTMLElement): { start: number; end: numb // startContainer comes after current node startOffset += textLength; } - + // Calculate end offset if (current === range.endContainer) { endOffset += range.endOffset; @@ -387,7 +389,7 @@ export const getSelectionOffsets = (el: HTMLElement): { start: number; end: numb } else { endOffset += textLength; } - + current = walker.nextNode(); } @@ -1032,7 +1034,7 @@ const moveToPreviousElement = ( 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, isFocused: false, focusOffsetStart: undefined, focusOffsetEnd: undefined }; } return el; }); @@ -1052,7 +1054,7 @@ const moveCaretBackward = ( ) => { 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, isFocused: true, focusOffsetStart: Math.max(0, caretOffset - 1), focusOffsetEnd: Math.max(0, caretOffset - 1) }; } return el; }); @@ -1072,18 +1074,7 @@ export const getWordBeforeCursor = (expressionModel: ExpressionModel[]): string return fullTextUpToCursor.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()) - ); -}; export const setCursorPositionToExpressionModel = (expressionModel: ExpressionModel[], cursorPosition: number): ExpressionModel[] => { const newExpressionModel = []; @@ -1213,3 +1204,61 @@ export const getModelWithModifiedParamsChip = (expressionModel: ExpressionModel[ 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()) + ); +}; 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 22e48b1252..b00f596db0 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts @@ -26,5 +26,5 @@ export * from "./MapEditor"; export * from "./FileSelect"; export * from "./FormMapEditor"; export * from "./FieldContext"; -export * from "./MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent"; +export * from "./MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; export { getPropertyFromFormField } from "./utils"; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/RecordConfigModal.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/RecordConfigModal.tsx index beba31edb0..57f4c05708 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/RecordConfigModal.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/RecordConfigModal.tsx @@ -22,7 +22,7 @@ import styled from "@emotion/styled"; import { useEffect, useRef, useState, RefObject } from "react"; import { useRpcContext } from "@wso2/ballerina-rpc-client"; import { RecordConfigView } from "./RecordConfigView"; -import { ChipExpressionBaseComponent, Context as FormContext, HelperpaneOnChangeOptions, FieldProvider, FormField, FormExpressionEditorProps, useFieldContext, getPropertyFromFormField } from "@wso2/ballerina-side-panel"; +import { ChipExpressionEditorComponent, Context as FormContext, HelperpaneOnChangeOptions, FieldProvider, FormField, FormExpressionEditorProps, useFieldContext, getPropertyFromFormField } from "@wso2/ballerina-side-panel"; import { useForm } from "react-hook-form"; import { debounce } from "lodash"; import ReactMarkdown from "react-markdown"; @@ -651,7 +651,7 @@ export function ConfigureRecordPage(props: ConfigureRecordPageProps) { triggerCharacters={triggerCharacters} >
- {formDiagnostics && formDiagnostics.length > 0 && ( d.message).join(', ')} />