From 3d5b104f673913da071e7a08c636af47496fb2e9 Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Wed, 12 Nov 2025 13:56:13 +0530 Subject: [PATCH 01/18] add code mirror basic functions --- common/config/rush/pnpm-lock.yaml | 17 +- .../ballerina-side-panel/package.json | 5 +- .../components/editors/ExpressionEditor.tsx | 2 +- .../components/editors/ExpressionField.tsx | 3 +- .../ChipExpressionBaseComponent.tsx | 23 +- .../ChipExpressionEditor/CodeUtils.ts | 272 ++++++++++++++++++ .../ChipExpressionBaseComponent2.tsx | 139 +++++++++ .../ChipExpressionEditor/utils.ts | 89 ++++-- 8 files changed, 512 insertions(+), 38 deletions(-) create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 2de699ac7a..3774f59c47 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -974,6 +974,15 @@ importers: ../../workspaces/ballerina/ballerina-side-panel: dependencies: + '@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) @@ -37332,8 +37341,8 @@ snapshots: '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.102.1)': dependencies: - webpack: 5.102.1(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack@5.102.1) + webpack: 5.102.1(@swc/core@1.15.0(@swc/helpers@0.5.17))(webpack-cli@6.0.1) + webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) '@webpack-cli/info@1.5.0(webpack-cli@4.10.0)': dependencies: @@ -37347,8 +37356,8 @@ snapshots: '@webpack-cli/info@3.0.1(webpack-cli@6.0.1)(webpack@5.102.1)': dependencies: - webpack: 5.102.1(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack@5.102.1) + webpack: 5.102.1(@swc/core@1.15.0(@swc/helpers@0.5.17))(webpack-cli@6.0.1) + webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) '@webpack-cli/serve@1.7.0(webpack-cli@4.10.0)': dependencies: diff --git a/workspaces/ballerina/ballerina-side-panel/package.json b/workspaces/ballerina/ballerina-side-panel/package.json index 8fcaa618b8..0c0913b52b 100644 --- a/workspaces/ballerina/ballerina-side-panel/package.json +++ b/workspaces/ballerina/ballerina-side-panel/package.json @@ -28,7 +28,10 @@ "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" }, "devDependencies": { "@storybook/react": "^6.5.16", 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 e609db9853..4e3a6e6dd5 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx @@ -525,7 +525,7 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { }; // Only allow opening expanded mode for specific fields - const onOpenExpandedMode = (!props.isInExpandedMode && ["query", "instructions", "role"].includes(field.key)) + const onOpenExpandedMode = (!props.isInExpandedMode && ["query", "instructions", "role"].includes(field.key)) || inputMode === InputMode.EXP ? handleOpenExpandedMode : undefined; 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 ff6e22e907..d535501312 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx @@ -31,6 +31,7 @@ import { InputMode } from './MultiModeExpressionEditor/ChipExpressionEditor/type import { ChipExpressionBaseComponent } from './MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent'; import { LineRange } from '@wso2/ballerina-core/lib/interfaces/common'; import { HelperpaneOnChangeOptions } from '../Form/types'; +import { ChipExpressionBaseComponent2 } from './MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2'; export interface ExpressionField { inputMode: InputMode; @@ -150,7 +151,7 @@ export const ExpressionField: React.FC = ({ } return ( - void; @@ -97,13 +98,15 @@ export const ChipExpressionBaseComponent = (props: ChipExpressionBaseComponentPr 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 || []; + // const response = await expressionEditorRpcManager?.getExpressionTokens( + // value, + // props.fileName, + // props.targetLineRange.startLine + // ); + // setIsLoading(false); + return new Promise((resolve) => { + resolve([]); + }); } catch (error) { setIsLoading(false); return []; @@ -517,7 +520,6 @@ export const ChipExpressionBaseComponent = (props: ChipExpressionBaseComponentPr {!props.isInExpandedMode && }
- {isLoading && } - + /> */} +
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..13262290fe --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts @@ -0,0 +1,272 @@ +/** + * 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 } from "@codemirror/state"; +import { WidgetType, Decoration, ViewPlugin, EditorView, ViewUpdate, keymap } from "@codemirror/view"; +import { ParsedToken, filterCompletionsByPrefixAndType, getParsedExpressionTokens, getWordBeforeCursor, getWordBeforeCursorPosition } from "./utils"; +import { defaultKeymap, historyKeymap } from "@codemirror/commands"; +import { CompletionItem } from "@wso2/ui-toolkit"; + +export type TokenStream = number[]; + +export function createChip(text: string) { + class ChipWidget extends WidgetType { + constructor(readonly text: string) { + super(); + } + toDOM() { + const span = document.createElement("span"); + span.textContent = this.text; + span.style.background = "#007bff"; + span.style.color = "white"; + span.style.borderRadius = "12px"; + span.style.padding = "2px 8px"; + span.style.margin = "0 2px"; + span.style.display = "inline-block"; + span.style.cursor = "pointer"; + return span; + } + ignoreEvent() { + return true; + } + eq(other: ChipWidget) { + return other.text === this.text; + } + } + return Decoration.replace({ + widget: new ChipWidget(text), + inclusive: false, + block: false + }); +} + +export const chipTheme = EditorView.theme({ + ".cm-content": { + caretColor: "#ffffff" + }, + "&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor": { + borderLeftColor: "#ffffff" + } + +}); + +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).range(token.start, token.end)); + } + + return Decoration.set(widgets, true); + } + }, + { + decorations: v => v.decorations + } +); + +export const expressionEditorKeymap = keymap.of([ + { + 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 +]); + +export const shouldOpenCompletionsListner = (onTrigger: (state: boolean, top: number, left: number, filteredCompletions: CompletionItem[]) => void, completions: CompletionItem[]) => { + const shouldOpenCompletionsListner = EditorView.updateListener.of((update) => { + const cursorPosition = update.view.state.selection.main.head; + const currentValue = update.view.state.doc.toString(); + const textBeforeCursor = currentValue.slice(0, cursorPosition); + + const wordBeforeCursor = getWordBeforeCursorPosition(textBeforeCursor); + if (update.docChanged && wordBeforeCursor.length > 0) { + const coords = update.view.coordsAtPos(cursorPosition); + if (coords && coords.top && coords.left) { + const newFilteredCompletions = filterCompletionsByPrefixAndType(completions, wordBeforeCursor); + onTrigger(true, coords.top, coords.left, newFilteredCompletions); + } + } + }) + + return shouldOpenCompletionsListner; +} + +export const shouldOpenHelperPaneState = (onTrigger: (state: boolean, top: number, left: number) => void) => { + const shouldOpenHelperPaneListner = EditorView.updateListener.of((update) => { + const cursorPosition = update.view.state.selection.main.head; + const currentValue = update.view.state.doc.toString(); + const textBeforeCursor = currentValue.slice(0, cursorPosition); + const triggerToken = textBeforeCursor.trimEnd().slice(-1); + const coords = update.view.coordsAtPos(cursorPosition); + + if (!update.view.hasFocus) { + onTrigger(false, 0, 0); + return; + } + if (coords && coords.top && coords.left && (update.view.hasFocus || triggerToken === '+' || triggerToken === ':')) { + 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(true, relativeTop, relativeLeft); + } + }); + 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, cursorPosition: number) => void) => { + const onChangeListner = EditorView.updateListener.of((update) => { + if (update.docChanged) { + const newValue = update.view.state.doc.toString(); + onTrigeer(newValue, update.view.state.selection.main.head); + } + }); + return onChangeListner; +} + +// export const cursorListener = EditorView.updateListener.of((update) => { +// if (update.selectionSet || update.docChanged) { +// console.log("Cursor or document changed"); +// } +// }); + +// export const focusOutListener = EditorView.updateListener.of((update) => { +// if (update.focusChanged && !update.view.hasFocus) { +// console.log("Editor lost focus"); +// } +// }); + +// export const focusInListener = EditorView.updateListener.of((update) => { +// if (update.focusChanged && update.view.hasFocus) { +// console.log("Editor gained focus"); +// } +// }); + +// export const onChangeListener = EditorView.updateListener.of((update) => { +// if (update.docChanged) { +// const newValue = update.view.state.doc.toString(); +// console.log("Document changed:", newValue); +// } +// }); + +// export const cursorPositionedAfterTriggerListener = EditorView.updateListener.of((update) => { +// if (update.selectionSet || update.docChanged) { +// const cursorPos = update.state.selection.main.head; +// const docText = update.state.doc.toString(); +// if (cursorPos > 0 && docText[cursorPos - 1] === '') { +// console.log("Cursor positioned after trigger character '#'"); +// } +// } +// }); diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx new file mode 100644 index 0000000000..1162255983 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -0,0 +1,139 @@ +/** + * 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 } from "@codemirror/view"; +import React, { useEffect, useRef, useState } from "react"; +import { ChipExpressionBaseComponentProps } from "../ChipExpressionBaseComponent"; +import { useFormContext } from "../../../../../context"; +import { buildNeedTokenRefetchListner, buildOnChangeListner, chipPlugin, chipTheme, tokenField, tokensChangeEffect, expressionEditorKeymap, shouldOpenHelperPaneState, shouldOpenCompletionsListner } from "../CodeUtils"; +import { history } from "@codemirror/commands"; +import { ContextMenuContainer } from "../styles"; + +type ContextMenuType = "HELPER_PANE" | "COMPLETIONS"; + +type HelperPaneState = { + isOpen: boolean; + top: number; + left: number; + type: ContextMenuType; +} + +export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentProps) => { + const [contextMenuState, setContextMenuState] = useState({ isOpen: false, top: 0, left: 0, type: "HELPER_PANE" as ContextMenuType }); + + const editorRef = useRef(null); + const viewRef = useRef(null); + const isTokenUpdateScheduled = useRef(true); + + const { expressionEditor } = useFormContext(); + const expressionEditorRpcManager = expressionEditor?.rpcManager; + + const needTokenRefetchListner = buildNeedTokenRefetchListner(() => { + isTokenUpdateScheduled.current = true; + }); + + const handleChangeListner = buildOnChangeListner((newValue, cursorPosition) => { + props.onChange(newValue, cursorPosition); + }); + + const handleHelperOpenListner = shouldOpenHelperPaneState((state, top, left) => { + setContextMenuState({ isOpen: state, top, left, type: "HELPER_PANE" }); + }); + + const handleCompletionsOpenListner = shouldOpenCompletionsListner((state, top, left) => { + setContextMenuState({ isOpen: state, top, left, type: "COMPLETIONS" }); + }, + props.completions + ); + + useEffect(() => { + if (!props.value || !viewRef.current) return; + const updateEditorState = async () => { + const currentDoc = viewRef.current!.state.doc.toString(); + if (currentDoc !== props.value) { + viewRef.current!.dispatch({ + changes: { from: 0, to: currentDoc.length, insert: props.value } + }); + } + + if (!isTokenUpdateScheduled.current) return; + const tokenStream = await expressionEditorRpcManager?.getExpressionTokens( + props.value, + props.fileName, + props.targetLineRange.startLine + ); + isTokenUpdateScheduled.current = false; + if (tokenStream) { + viewRef.current!.dispatch({ + effects: tokensChangeEffect.of(tokenStream) + }); + } + }; + updateEditorState(); + }, [props.value, props.fileName, props.targetLineRange.startLine]); + + useEffect(() => { + if (!editorRef.current) return; + const startState = EditorState.create({ + doc: props.value ?? "", + extensions: [ + history(), + expressionEditorKeymap, + chipPlugin, + tokenField, + chipTheme, + EditorView.lineWrapping, + needTokenRefetchListner, + handleChangeListner, + handleHelperOpenListner, + handleCompletionsOpenListner + ] + }); + const view = new EditorView({ + state: startState, + parent: editorRef.current + }); + viewRef.current = view; + return () => { + view.destroy(); + }; + }, []); + + return ( +
+
+ +
+ {contextMenuState.isOpen && + contextMenuState.type === "HELPER_PANE" && + ( + + {props.getHelperPane( + props.value, + () => { }, + "3/4" + )} + + )} +
+ ); +} 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 72a761ee39..b03c4fb994 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 @@ -195,7 +195,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,6 +307,8 @@ export const createExpressionModelFromTokens = ( return expressionModel; }; + + const getTokenTypeFromIndex = (index: number): string => { const tokenTypes: { [key: number]: string } = { 0: '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,59 @@ 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; +} + +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]; + + 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 }); + } + 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()) + ); +}; From 968718fce283d07a6e31808eaacf563723864997 Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 09:42:03 +0530 Subject: [PATCH 02/18] add completions --- common/config/rush/pnpm-lock.yaml | 11 +- .../ballerina-side-panel/package.json | 3 +- .../components/editors/ExpressionField.tsx | 1 - .../ChipExpressionBaseComponent.tsx | 5 +- .../ChipExpressionEditor/CodeUtils.ts | 64 +++++- .../ChipExpressionBaseComponent2.tsx | 196 +++++++++++++++--- 6 files changed, 236 insertions(+), 44 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 3774f59c47..3cf8b1efab 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -974,6 +974,9 @@ 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 @@ -37341,8 +37344,8 @@ snapshots: '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.102.1)': dependencies: - webpack: 5.102.1(@swc/core@1.15.0(@swc/helpers@0.5.17))(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack: 5.102.1(webpack-cli@6.0.1) + webpack-cli: 6.0.1(webpack@5.102.1) '@webpack-cli/info@1.5.0(webpack-cli@4.10.0)': dependencies: @@ -37356,8 +37359,8 @@ snapshots: '@webpack-cli/info@3.0.1(webpack-cli@6.0.1)(webpack@5.102.1)': dependencies: - webpack: 5.102.1(@swc/core@1.15.0(@swc/helpers@0.5.17))(webpack-cli@6.0.1) - webpack-cli: 6.0.1(webpack-dev-server@5.2.2)(webpack@5.102.1) + webpack: 5.102.1(webpack-cli@6.0.1) + webpack-cli: 6.0.1(webpack@5.102.1) '@webpack-cli/serve@1.7.0(webpack-cli@4.10.0)': dependencies: diff --git a/workspaces/ballerina/ballerina-side-panel/package.json b/workspaces/ballerina/ballerina-side-panel/package.json index 0c0913b52b..04324c9a4d 100644 --- a/workspaces/ballerina/ballerina-side-panel/package.json +++ b/workspaces/ballerina/ballerina-side-panel/package.json @@ -31,7 +31,8 @@ "@github/markdown-toolbar-element": "^2.2.3", "@codemirror/commands": "~6.10.0", "@codemirror/state": "~6.5.2", - "@codemirror/view": "~6.38.6" + "@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/ExpressionField.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx index d535501312..81c8ea3e2b 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx @@ -28,7 +28,6 @@ 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 { ChipExpressionBaseComponent2 } from './MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2'; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent.tsx index 3476017f32..4b794fc822 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent.tsx @@ -18,10 +18,9 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from "react"; import FXButton from "./components/FxButton"; -import { ChipEditorContainer, SkeletonLoader } from "./styles"; +import { ChipEditorContainer } from "./styles"; import { ExpressionModel } from "./types"; import { AutoExpandingEditableDiv } from "./components/AutoExpandingEditableDiv"; -import { TokenizedExpression } from "./components/TokenizedExpression"; import { getAbsoluteCaretPosition, mapAbsoluteToModel, @@ -42,7 +41,6 @@ import { useFormContext } from "../../../../context"; import { DATA_ELEMENT_ID_ATTRIBUTE, FOCUS_MARKER, ARROW_LEFT_MARKER, ARROW_RIGHT_MARKER, BACKSPACE_MARKER, COMPLETIONS_MARKER, HELPER_MARKER, DELETE_MARKER } from "./constants"; import { LineRange } from "@wso2/ballerina-core/lib/interfaces/common"; import { HelperpaneOnChangeOptions } from "../../../Form/types"; -import { ChipExpressionBaseComponent2 } from "./components/ChipExpressionBaseComponent2"; export type ChipExpressionBaseComponentProps = { onTokenRemove?: (token: string) => void; @@ -553,7 +551,6 @@ export const ChipExpressionBaseComponent = (props: ChipExpressionBaseComponentPr onChipFocus={handleChipFocus} onChipBlur={handleChipBlur} /> */} - 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 13262290fe..6016182f87 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 @@ -24,6 +24,12 @@ import { CompletionItem } from "@wso2/ui-toolkit"; export type TokenStream = number[]; +export type CursorInfo = { + top: number; + left: number; + position: number; +} + export function createChip(text: string) { class ChipWidget extends WidgetType { constructor(readonly text: string) { @@ -126,7 +132,7 @@ export const chipPlugin = ViewPlugin.fromClass( } ); -export const expressionEditorKeymap = keymap.of([ +export const expressionEditorKeymap = [ { key: "Backspace", run: (view) => { @@ -150,9 +156,9 @@ export const expressionEditorKeymap = keymap.of([ }, ...defaultKeymap, ...historyKeymap -]); +]; -export const shouldOpenCompletionsListner = (onTrigger: (state: boolean, top: number, left: number, filteredCompletions: CompletionItem[]) => void, completions: CompletionItem[]) => { +export const onWordType = (onTrigger: (cursor: CursorInfo, wordBeforeCursor: string) => void) => { const shouldOpenCompletionsListner = EditorView.updateListener.of((update) => { const cursorPosition = update.view.state.selection.main.head; const currentValue = update.view.state.doc.toString(); @@ -162,8 +168,26 @@ export const shouldOpenCompletionsListner = (onTrigger: (state: boolean, top: nu if (update.docChanged && wordBeforeCursor.length > 0) { const coords = update.view.coordsAtPos(cursorPosition); if (coords && coords.top && coords.left) { - const newFilteredCompletions = filterCompletionsByPrefixAndType(completions, wordBeforeCursor); - onTrigger(true, coords.top, coords.left, newFilteredCompletions); + 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 CursorInfo = { + top: relativeTop, + left: relativeLeft, + position: cursorPosition + } + onTrigger(CursorInfo, wordBeforeCursor); } } }) @@ -226,11 +250,37 @@ export const buildNeedTokenRefetchListner = (onTrigger: () => void) => { return needTokenRefetchListner; } -export const buildOnChangeListner = (onTrigeer: (newValue: string, cursorPosition: number) => void) => { +export const buildOnChangeListner = (onTrigeer: (newValue: string, cursor: CursorInfo) => void) => { const onChangeListner = EditorView.updateListener.of((update) => { + const cursorPos = update.view.state.selection.main.head; + const coords = update.view.coordsAtPos(cursorPos); + + 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(); - onTrigeer(newValue, update.view.state.selection.main.head); + const cursorInfo = { + top: relativeTop, + left: relativeLeft, + position: cursorPos + }; + onTrigeer(newValue, cursorInfo); } }); return onChangeListner; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx index 1162255983..338ece5dc9 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -17,13 +17,17 @@ */ import { EditorState } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; +import { EditorView, keymap } from "@codemirror/view"; import React, { useEffect, useRef, useState } from "react"; import { ChipExpressionBaseComponentProps } from "../ChipExpressionBaseComponent"; import { useFormContext } from "../../../../../context"; -import { buildNeedTokenRefetchListner, buildOnChangeListner, chipPlugin, chipTheme, tokenField, tokensChangeEffect, expressionEditorKeymap, shouldOpenHelperPaneState, shouldOpenCompletionsListner } from "../CodeUtils"; +import { buildNeedTokenRefetchListner, buildOnChangeListner, chipPlugin, chipTheme, tokenField, tokensChangeEffect, expressionEditorKeymap, shouldOpenHelperPaneState, onWordType, CursorInfo } from "../CodeUtils"; import { history } from "@codemirror/commands"; -import { ContextMenuContainer } from "../styles"; +import { Completions, ContextMenuContainer } from "../styles"; +import { HelperpaneOnChangeOptions } from "../../../../Form/types"; +import { CompletionItem, HelperPaneHeight } from "@wso2/ui-toolkit"; +import { CompletionsItem } from "./CompletionsItem"; +import { filterCompletionsByPrefixAndType, getWordBeforeCursorPosition } from "../utils"; type ContextMenuType = "HELPER_PANE" | "COMPLETIONS"; @@ -36,6 +40,9 @@ type HelperPaneState = { export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentProps) => { const [contextMenuState, setContextMenuState] = useState({ isOpen: false, top: 0, left: 0, type: "HELPER_PANE" as ContextMenuType }); + const [completionPrefix, setCompletionPrefix] = useState(""); + const [filteredCompletions, setFilteredCompletions] = useState([]); + const [selectedCompletionItem, setSelectedCompletionItem] = useState(0); const editorRef = useRef(null); const viewRef = useRef(null); @@ -48,19 +55,100 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP isTokenUpdateScheduled.current = true; }); - const handleChangeListner = buildOnChangeListner((newValue, cursorPosition) => { - props.onChange(newValue, cursorPosition); - }); + const handleChangeListner = buildOnChangeListner((newValue, cursor) => { + props.onChange(newValue, cursor.position); + const textBeforeCursor = newValue.slice(0, cursor.position); + const lastNonSpaceChar = textBeforeCursor.trimEnd().slice(-1); + const isTrigger = lastNonSpaceChar === '+' || lastNonSpaceChar === ':'; + const wordBeforeCursor = getWordBeforeCursorPosition(textBeforeCursor); + setCompletionPrefix(wordBeforeCursor); + + if (isTrigger) { + setContextMenuState({ isOpen: true, top: cursor.top, left: cursor.left, type: "HELPER_PANE" }); + return; + } - const handleHelperOpenListner = shouldOpenHelperPaneState((state, top, left) => { - setContextMenuState({ isOpen: state, top, left, type: "HELPER_PANE" }); + // Only show completions if we have a word to complete + if (wordBeforeCursor.length > 0) { + setContextMenuState({ isOpen: true, top: cursor.top, left: cursor.left, type: "COMPLETIONS" }); + } else { + setContextMenuState({ isOpen: false, top: 0, left: 0, type: "COMPLETIONS" }); + } }); - const handleCompletionsOpenListner = shouldOpenCompletionsListner((state, top, left) => { - setContextMenuState({ isOpen: state, top, left, type: "COMPLETIONS" }); - }, - props.completions - ); + const handleCompletionHover = (index: number) => { + setSelectedCompletionItem(index); + }; + + const handleCompletionSelect = (item: CompletionItem) => { + if (!viewRef.current) return; + const view = viewRef.current as EditorView; + const wordBeforeCursor = getWordBeforeCursorPosition(view.state.doc.toString().slice(0, view.state.selection.main.head)); + const from = view.state.selection.main.head - wordBeforeCursor.length; + const to = view.state.selection.main.head; + + view.dispatch({ + changes: { from, to, insert: item.label }, + selection: { anchor: from + item.label.length } + }); + setContextMenuState(prev => ({ ...prev, isOpen: false })); + }; + + const completionKeymap = [ + { + key: "ArrowDown", + run: (_view) => { + if (contextMenuState.type !== "COMPLETIONS" || !contextMenuState.isOpen) return false; + setSelectedCompletionItem(prev => + prev < filteredCompletions.length - 1 ? prev + 1 : prev + ); + return true; + } + }, + { + key: "ArrowUp", + run: (_view) => { + if (contextMenuState.type !== "COMPLETIONS" || !contextMenuState.isOpen) return false; + setSelectedCompletionItem(prev => + prev > 0 ? prev - 1 : -1 + ); + return true; + } + }, + { + key: "Enter", + run: (_view) => { + if (contextMenuState.type !== "COMPLETIONS" || !contextMenuState.isOpen) return false; + if (selectedCompletionItem >= 0 && selectedCompletionItem < filteredCompletions.length) { + handleCompletionSelect(filteredCompletions[selectedCompletionItem]); + return true; + } + return false; + } + }, + { + key: "Escape", + run: (_view) => { + if (!contextMenuState.isOpen) return false; + setContextMenuState(prev => ({ ...prev, isOpen: false })); + return true; + } + }, + ...expressionEditorKeymap + ]; + + useEffect(() => { + const filteredCompletions = filterCompletionsByPrefixAndType(props.completions, completionPrefix); + setFilteredCompletions(filteredCompletions); + + if (contextMenuState.type === "COMPLETIONS") { + if (filteredCompletions.length === 0 || completionPrefix.length === 0) { + setContextMenuState(prev => ({ ...prev, isOpen: false })); + } else { + setContextMenuState(prev => ({ ...prev, isOpen: true })); + } + } + }, [props.completions, completionPrefix]); useEffect(() => { if (!props.value || !viewRef.current) return; @@ -94,15 +182,13 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP doc: props.value ?? "", extensions: [ history(), - expressionEditorKeymap, + keymap.of(completionKeymap), chipPlugin, tokenField, chipTheme, EditorView.lineWrapping, needTokenRefetchListner, handleChangeListner, - handleHelperOpenListner, - handleCompletionsOpenListner ] }); const view = new EditorView({ @@ -121,19 +207,75 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP {contextMenuState.isOpen && - contextMenuState.type === "HELPER_PANE" && - ( - - {props.getHelperPane( - props.value, - () => { }, - "3/4" - )} - - )} + getHelperPane={props.getHelperPane} + value={props.value} + type={contextMenuState.type} + completions={filteredCompletions} + selectedCompletionItem={selectedCompletionItem} + onCompletionSelect={handleCompletionSelect} + onCompletionHover={handleCompletionHover} + /> + } ); } + +type ContextMenuProps = { + top: number; + left: number; + getHelperPane: ( + value: string, + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, + helperPaneHeight: HelperPaneHeight + ) => React.ReactNode; + value: string; + type: ContextMenuType; + completions: CompletionItem[]; + selectedCompletionItem: number; + onCompletionSelect: (item: CompletionItem) => void; + onCompletionHover: (index: number) => void; +} + +export const ContextMenu = (props: ContextMenuProps) => { + if (props.type === "COMPLETIONS") { + if (props.completions.length === 0) { + return null; + } + return ( + + + {props.completions.map((item, index) => ( + props.onCompletionSelect?.(item)} + onMouseEnter={() => props.onCompletionHover?.(index)} + /> + ))} + + + ); + } + else if (props.type === "HELPER_PANE") { + return ( + + {props.getHelperPane( + props.value, + () => { }, + "3/4" + )} + + ); + } + return null; +} From df64dc4e1cb59e297e92b2a8b29dc2e5c5e05616 Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 12:23:37 +0530 Subject: [PATCH 03/18] Add helperpane support --- .../ChipExpressionEditor/CodeUtils.ts | 260 ++++++++++------ .../ChipExpressionBaseComponent2.tsx | 286 +++++++++--------- .../ChipExpressionEditor/styles.tsx | 2 +- .../ChipExpressionEditor/utils.ts | 12 +- 4 files changed, 308 insertions(+), 252 deletions(-) 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 6016182f87..388d3f26cf 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 @@ -16,46 +16,85 @@ * under the License. */ -import { StateEffect, StateField, RangeSet, Transaction } from "@codemirror/state"; -import { WidgetType, Decoration, ViewPlugin, EditorView, ViewUpdate, keymap } from "@codemirror/view"; +import { StateEffect, StateField, RangeSet, Transaction, SelectionRange } 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: number; + position: SelectionRange; } -export function createChip(text: string) { +export type TokenType = 'variable' | 'property' | 'parameter'; + +export function createChip(text: string, type: TokenType, start: number, end: number, view: EditorView) { class ChipWidget extends WidgetType { - constructor(readonly text: string) { + 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.text; - span.style.background = "#007bff"; - span.style.color = "white"; - span.style.borderRadius = "12px"; - span.style.padding = "2px 8px"; - span.style.margin = "0 2px"; + 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"; + span.style.webkitUserSelect = "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 true; + return false; // Allow click events } eq(other: ChipWidget) { - return other.text === this.text; + return other.text === this.text && other.start === this.start && other.end === this.end; } } return Decoration.replace({ - widget: new ChipWidget(text), + widget: new ChipWidget(text, type, start, end, view), inclusive: false, block: false }); @@ -68,7 +107,47 @@ export const chipTheme = EditorView.theme({ "&.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(); @@ -121,7 +200,7 @@ export const chipPlugin = ViewPlugin.fromClass( for (const token of tokens) { const text = view.state.doc.sliceString(token.start, token.end); - widgets.push(createChip(text).range(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); @@ -158,15 +237,18 @@ export const expressionEditorKeymap = [ ...historyKeymap ]; -export const onWordType = (onTrigger: (cursor: CursorInfo, wordBeforeCursor: string) => void) => { - const shouldOpenCompletionsListner = EditorView.updateListener.of((update) => { - const cursorPosition = update.view.state.selection.main.head; - const currentValue = update.view.state.doc.toString(); - const textBeforeCursor = currentValue.slice(0, cursorPosition); +// 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); - const wordBeforeCursor = getWordBeforeCursorPosition(textBeforeCursor); - if (update.docChanged && wordBeforeCursor.length > 0) { - const coords = update.view.coordsAtPos(cursorPosition); if (coords && coords.top && coords.left) { const editorRect = update.view.dom.getBoundingClientRect(); //+5 is to position a little be below the cursor @@ -182,48 +264,19 @@ export const onWordType = (onTrigger: (cursor: CursorInfo, wordBeforeCursor: str if (overflow > 0) { relativeLeft -= overflow; } - const CursorInfo = { - top: relativeTop, - left: relativeLeft, - position: cursorPosition - } - onTrigger(CursorInfo, wordBeforeCursor); + + onTrigger({ top: relativeTop, left: relativeLeft, position: cursorPosition }); } } - }) - - return shouldOpenCompletionsListner; -} + }); + return shouldOpenHelperPaneListner; +}; -export const shouldOpenHelperPaneState = (onTrigger: (state: boolean, top: number, left: number) => void) => { +export const buildOnFocusOutListner = (onTrigger: () => void) => { const shouldOpenHelperPaneListner = EditorView.updateListener.of((update) => { - const cursorPosition = update.view.state.selection.main.head; - const currentValue = update.view.state.doc.toString(); - const textBeforeCursor = currentValue.slice(0, cursorPosition); - const triggerToken = textBeforeCursor.trimEnd().slice(-1); - const coords = update.view.coordsAtPos(cursorPosition); - - if (!update.view.hasFocus) { - onTrigger(false, 0, 0); - return; - } - if (coords && coords.top && coords.left && (update.view.hasFocus || triggerToken === '+' || triggerToken === ':')) { - 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(true, relativeTop, relativeLeft); + if (update.focusChanged) { + if (update.view.hasFocus) return; + onTrigger(); } }); return shouldOpenHelperPaneListner; @@ -252,8 +305,8 @@ export const buildNeedTokenRefetchListner = (onTrigger: () => void) => { export const buildOnChangeListner = (onTrigeer: (newValue: string, cursor: CursorInfo) => void) => { const onChangeListner = EditorView.updateListener.of((update) => { - const cursorPos = update.view.state.selection.main.head; - const coords = update.view.coordsAtPos(cursorPos); + const cursorPos = update.view.state.selection.main; + const coords = update.view.coordsAtPos(cursorPos.to); if (!coords || coords.top === null || coords.left === null) { throw new Error("Could not get cursor coordinates"); @@ -286,37 +339,50 @@ export const buildOnChangeListner = (onTrigeer: (newValue: string, cursor: Curso return onChangeListner; } -// export const cursorListener = EditorView.updateListener.of((update) => { -// if (update.selectionSet || update.docChanged) { -// console.log("Cursor or document changed"); -// } -// }); - -// export const focusOutListener = EditorView.updateListener.of((update) => { -// if (update.focusChanged && !update.view.hasFocus) { -// console.log("Editor lost focus"); -// } -// }); - -// export const focusInListener = EditorView.updateListener.of((update) => { -// if (update.focusChanged && update.view.hasFocus) { -// console.log("Editor gained focus"); -// } -// }); - -// export const onChangeListener = EditorView.updateListener.of((update) => { -// if (update.docChanged) { -// const newValue = update.view.state.doc.toString(); -// console.log("Document changed:", newValue); -// } -// }); - -// export const cursorPositionedAfterTriggerListener = EditorView.updateListener.of((update) => { -// if (update.selectionSet || update.docChanged) { -// const cursorPos = update.state.selection.main.head; -// const docText = update.state.doc.toString(); -// if (cursorPos > 0 && docText[cursorPos - 1] === '') { -// console.log("Cursor positioned after trigger character '#'"); -// } -// } -// }); +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 = (isHelperPaneOpen: boolean, onClose: () => void) => { + return [ + { + key: "Escape", + run: (_view) => { + if (!isHelperPaneOpen) return false; + onClose(); + return true; + } + } + ]; +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx index 338ece5dc9..b04b259b47 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -21,134 +21,110 @@ import { EditorView, keymap } from "@codemirror/view"; import React, { useEffect, useRef, useState } from "react"; import { ChipExpressionBaseComponentProps } from "../ChipExpressionBaseComponent"; import { useFormContext } from "../../../../../context"; -import { buildNeedTokenRefetchListner, buildOnChangeListner, chipPlugin, chipTheme, tokenField, tokensChangeEffect, expressionEditorKeymap, shouldOpenHelperPaneState, onWordType, CursorInfo } from "../CodeUtils"; +import { buildNeedTokenRefetchListner, buildOnChangeListner, chipPlugin, chipTheme, completionTheme, tokenField, tokensChangeEffect, expressionEditorKeymap, buildCompletionSource, buildHelperPaneKeymap, buildOnFocusListner, CursorInfo, buildOnFocusOutListner } from "../CodeUtils"; import { history } from "@codemirror/commands"; -import { Completions, ContextMenuContainer } from "../styles"; +import { autocompletion } from "@codemirror/autocomplete"; +import { ContextMenuContainer, FloatingButtonContainer, FloatingToggleButton } from "../styles"; import { HelperpaneOnChangeOptions } from "../../../../Form/types"; -import { CompletionItem, HelperPaneHeight } from "@wso2/ui-toolkit"; -import { CompletionsItem } from "./CompletionsItem"; -import { filterCompletionsByPrefixAndType, getWordBeforeCursorPosition } from "../utils"; - -type ContextMenuType = "HELPER_PANE" | "COMPLETIONS"; +import { HelperPaneHeight } from "@wso2/ui-toolkit"; +import { CloseHelperButton, OpenHelperButton } from "./FloatingButtonIcons"; type HelperPaneState = { isOpen: boolean; top: number; left: number; - type: ContextMenuType; } export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentProps) => { - const [contextMenuState, setContextMenuState] = useState({ isOpen: false, top: 0, left: 0, type: "HELPER_PANE" as ContextMenuType }); - const [completionPrefix, setCompletionPrefix] = useState(""); - const [filteredCompletions, setFilteredCompletions] = useState([]); - const [selectedCompletionItem, setSelectedCompletionItem] = useState(0); + const [helperPaneState, setHelperPaneState] = useState({ isOpen: false, top: 0, left: 0 }); const editorRef = useRef(null); + const helperPaneRef = useRef(null); const viewRef = useRef(null); - const isTokenUpdateScheduled = useRef(true); + const [isTokenUpdateScheduled, setIsTokenUpdateScheduled] = useState(true); + const completionsRef = useRef(props.completions); + const helperPaneToggleButtonRef = useRef(null); const { expressionEditor } = useFormContext(); const expressionEditorRpcManager = expressionEditor?.rpcManager; const needTokenRefetchListner = buildNeedTokenRefetchListner(() => { - isTokenUpdateScheduled.current = true; + setIsTokenUpdateScheduled(true); }); const handleChangeListner = buildOnChangeListner((newValue, cursor) => { - props.onChange(newValue, cursor.position); - const textBeforeCursor = newValue.slice(0, cursor.position); + 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 wordBeforeCursor = getWordBeforeCursorPosition(textBeforeCursor); - setCompletionPrefix(wordBeforeCursor); if (isTrigger) { - setContextMenuState({ isOpen: true, top: cursor.top, left: cursor.left, type: "HELPER_PANE" }); - return; - } - - // Only show completions if we have a word to complete - if (wordBeforeCursor.length > 0) { - setContextMenuState({ isOpen: true, top: cursor.top, left: cursor.left, type: "COMPLETIONS" }); + setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); } else { - setContextMenuState({ isOpen: false, top: 0, left: 0, type: "COMPLETIONS" }); + setHelperPaneState({ isOpen: false, top: 0, left: 0 }); } }); - const handleCompletionHover = (index: number) => { - setSelectedCompletionItem(index); - }; + const handleFocusListner = buildOnFocusListner((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 handleCompletionSelect = (item: CompletionItem) => { + const onHelperItemSelect = (value: string, options: HelperpaneOnChangeOptions) => { if (!viewRef.current) return; - const view = viewRef.current as EditorView; - const wordBeforeCursor = getWordBeforeCursorPosition(view.state.doc.toString().slice(0, view.state.selection.main.head)); - const from = view.state.selection.main.head - wordBeforeCursor.length; - const to = view.state.selection.main.head; + const view = viewRef.current; + const { from, to } = view.state.selection.main; view.dispatch({ - changes: { from, to, insert: item.label }, - selection: { anchor: from + item.label.length } + changes: { from, to, insert: value }, + selection: { anchor: from + value.length } }); - setContextMenuState(prev => ({ ...prev, isOpen: false })); - }; - const completionKeymap = [ - { - key: "ArrowDown", - run: (_view) => { - if (contextMenuState.type !== "COMPLETIONS" || !contextMenuState.isOpen) return false; - setSelectedCompletionItem(prev => - prev < filteredCompletions.length - 1 ? prev + 1 : prev - ); - return true; - } - }, - { - key: "ArrowUp", - run: (_view) => { - if (contextMenuState.type !== "COMPLETIONS" || !contextMenuState.isOpen) return false; - setSelectedCompletionItem(prev => - prev > 0 ? prev - 1 : -1 - ); - return true; - } - }, - { - key: "Enter", - run: (_view) => { - if (contextMenuState.type !== "COMPLETIONS" || !contextMenuState.isOpen) return false; - if (selectedCompletionItem >= 0 && selectedCompletionItem < filteredCompletions.length) { - handleCompletionSelect(filteredCompletions[selectedCompletionItem]); - return true; - } - return false; - } - }, - { - key: "Escape", - run: (_view) => { - if (!contextMenuState.isOpen) return false; - setContextMenuState(prev => ({ ...prev, isOpen: false })); - return true; - } - }, - ...expressionEditorKeymap - ]; + const newDoc = view.state.doc.toString(); + props.onChange(newDoc, from + value.length); + if (options.closeHelperPane) { + setIsTokenUpdateScheduled(true); + } + setHelperPaneState(prev => ({ ...prev, isOpen: !options.closeHelperPane })); + } - useEffect(() => { - const filteredCompletions = filterCompletionsByPrefixAndType(props.completions, completionPrefix); - setFilteredCompletions(filteredCompletions); + 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; - if (contextMenuState.type === "COMPLETIONS") { - if (filteredCompletions.length === 0 || completionPrefix.length === 0) { - setContextMenuState(prev => ({ ...prev, isOpen: false })); - } else { - setContextMenuState(prev => ({ ...prev, isOpen: true })); - } + // 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; } - }, [props.completions, completionPrefix]); + + setHelperPaneState(prev => ({ + ...prev, + top, + left, + isOpen: !prev.isOpen + })); + } useEffect(() => { if (!props.value || !viewRef.current) return; @@ -160,13 +136,13 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP }); } - if (!isTokenUpdateScheduled.current) return; + if (!isTokenUpdateScheduled) return; const tokenStream = await expressionEditorRpcManager?.getExpressionTokens( props.value, props.fileName, props.targetLineRange.startLine ); - isTokenUpdateScheduled.current = false; + setIsTokenUpdateScheduled(false); if (tokenStream) { viewRef.current!.dispatch({ effects: tokensChangeEffect.of(tokenStream) @@ -174,7 +150,7 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP } }; updateEditorState(); - }, [props.value, props.fileName, props.targetLineRange.startLine]); + }, [props.value, props.fileName, props.targetLineRange.startLine, isTokenUpdateScheduled]); useEffect(() => { if (!editorRef.current) return; @@ -182,13 +158,21 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP doc: props.value ?? "", extensions: [ history(), - keymap.of(completionKeymap), + keymap.of([...helperPaneKeymap, ...expressionEditorKeymap]), + autocompletion({ + override: [completionSource], + activateOnTyping: true, + closeOnBlur: true + }), chipPlugin, tokenField, chipTheme, + completionTheme, EditorView.lineWrapping, needTokenRefetchListner, handleChangeListner, + handleFocusListner, + handleFocusOutListner ] }); const view = new EditorView({ @@ -201,29 +185,62 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP }; }, []); + // 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 })); + } + }; + if (helperPaneState.isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [helperPaneState.isOpen]); + return (
- {contextMenuState.isOpen && - } + + + {helperPaneState.isOpen ? : } + +
); } -type ContextMenuProps = { +type HelperPaneProps = { top: number; left: number; getHelperPane: ( @@ -232,50 +249,21 @@ type ContextMenuProps = { helperPaneHeight: HelperPaneHeight ) => React.ReactNode; value: string; - type: ContextMenuType; - completions: CompletionItem[]; - selectedCompletionItem: number; - onCompletionSelect: (item: CompletionItem) => void; - onCompletionHover: (index: number) => void; + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void; } -export const ContextMenu = (props: ContextMenuProps) => { - if (props.type === "COMPLETIONS") { - if (props.completions.length === 0) { - return null; - } - return ( - - - {props.completions.map((item, index) => ( - props.onCompletionSelect?.(item)} - onMouseEnter={() => props.onCompletionHover?.(index)} - /> - ))} - - - ); - } - else if (props.type === "HELPER_PANE") { - return ( - - {props.getHelperPane( - props.value, - () => { }, - "3/4" - )} - - ); - } - return null; -} +export const HelperPane = React.forwardRef((props, ref) => { + return ( + + {props.getHelperPane( + props.value, + props.onChange, + "3/4" + )} + + ); +}); 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 2d2a03e413..9e87c4818b 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 b03c4fb994..5fbd80153c 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; @@ -309,12 +310,11 @@ export const createExpressionModelFromTokens = ( -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'; }; @@ -1213,6 +1213,7 @@ export type ParsedToken = { id:number; start: number; end: number; + type: TokenType; } export const getParsedExpressionTokens = (tokens: number[], value: string) => { @@ -1226,6 +1227,7 @@ export const getParsedExpressionTokens = (tokens: number[], value: string) => { 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) { @@ -1237,7 +1239,7 @@ export const getParsedExpressionTokens = (tokens: number[], value: string) => { const absoluteStart = getAbsoluteColumnOffset(value, currentLine, currentChar); const absoluteEnd = absoluteStart + length; - tokenObjects.push({ id: tokenId++, start: absoluteStart, end: absoluteEnd }); + tokenObjects.push({ id: tokenId++, start: absoluteStart, end: absoluteEnd, type: getTokenTypeFromIndex(type) }); } return tokenObjects; } From 2e7a73d03ecaae50edb109c84eeb30c6c10fbf24 Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 12:35:54 +0530 Subject: [PATCH 04/18] fix expanded mode after sync --- .../editors/ExpandedEditor/modes/ExpressionMode.tsx | 4 ++-- .../ChipExpressionEditor/ChipExpressionBaseComponent.tsx | 4 ++-- .../components/ChipExpressionBaseComponent2.tsx | 7 ++++++- 3 files changed, 10 insertions(+), 5 deletions(-) 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..19f745ea56 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 { ChipExpressionBaseComponent2 } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2"; const ExpressionContainer = styled.div` width: 100%; @@ -48,7 +48,7 @@ export const ExpressionMode: React.FC = ({ return ( - - + /> */} {props.onRemove && ( diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx index b04b259b47..9b18e3a1a4 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -27,7 +27,7 @@ import { autocompletion } from "@codemirror/autocomplete"; import { ContextMenuContainer, FloatingButtonContainer, FloatingToggleButton } from "../styles"; import { HelperpaneOnChangeOptions } from "../../../../Form/types"; import { HelperPaneHeight } from "@wso2/ui-toolkit"; -import { CloseHelperButton, OpenHelperButton } from "./FloatingButtonIcons"; +import { CloseHelperButton, ExpandButton, OpenHelperButton } from "./FloatingButtonIcons"; type HelperPaneState = { isOpen: boolean; @@ -235,6 +235,11 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP > {helperPaneState.isOpen ? : } + {props.onOpenExpandedMode && !props.isInExpandedMode && ( + + + + )} ); From 96d30a5a624d8d78d24e1f672f9add501a74db36 Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 17:43:51 +0530 Subject: [PATCH 05/18] fix expanded editor with new code mirror changes --- .../ExpandedEditor/modes/ExpressionMode.tsx | 1 + .../components/editors/ExpressionField.tsx | 1 + .../ChipExpressionBaseComponent.tsx | 603 ------------------ .../ChipExpressionEditor/CodeUtils.ts | 5 +- .../ChipExpressionBaseComponent2.tsx | 94 +-- 5 files changed, 61 insertions(+), 643 deletions(-) delete mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionBaseComponent.tsx 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 19f745ea56..4ee297c265 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 @@ -57,6 +57,7 @@ export const ExpressionMode: React.FC = ({ 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 94216ba062..91037effee 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx @@ -152,6 +152,7 @@ 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; -} - -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 new Promise((resolve) => { - resolve([]); - }); - } 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 index 388d3f26cf..009f7f976f 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 @@ -72,8 +72,6 @@ export function createChip(text: string, type: TokenType, start: number, end: nu span.style.outline = "none"; span.style.verticalAlign = "middle"; span.style.userSelect = "none"; - span.style.webkitUserSelect = "none"; - // Add click handler to select the chip text span.addEventListener("click", (event) => { event.preventDefault(); @@ -87,7 +85,7 @@ export function createChip(text: string, type: TokenType, start: number, end: nu return span; } ignoreEvent() { - return false; // Allow click events + return false; } eq(other: ChipWidget) { return other.text === this.text && other.start === this.start && other.end === this.end; @@ -311,6 +309,7 @@ export const buildOnChangeListner = (onTrigeer: (newValue: string, cursor: Curso if (!coords || coords.top === null || coords.left === null) { throw new Error("Could not get cursor coordinates"); } + const userEvent = update.transactions[0]?.annotation(Transaction.userEvent); if (update.docChanged) { const editorRect = update.view.dom.getBoundingClientRect(); //+5 is to position a little be below the cursor diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx index 9b18e3a1a4..c52d6c6edb 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -19,21 +19,45 @@ import { EditorState } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; import React, { useEffect, useRef, useState } from "react"; -import { ChipExpressionBaseComponentProps } from "../ChipExpressionBaseComponent"; import { useFormContext } from "../../../../../context"; import { buildNeedTokenRefetchListner, buildOnChangeListner, chipPlugin, chipTheme, completionTheme, tokenField, tokensChangeEffect, expressionEditorKeymap, buildCompletionSource, buildHelperPaneKeymap, buildOnFocusListner, CursorInfo, buildOnFocusOutListner } from "../CodeUtils"; import { history } from "@codemirror/commands"; import { autocompletion } from "@codemirror/autocomplete"; import { ContextMenuContainer, FloatingButtonContainer, FloatingToggleButton } from "../styles"; import { HelperpaneOnChangeOptions } from "../../../../Form/types"; -import { HelperPaneHeight } from "@wso2/ui-toolkit"; +import { CompletionItem, FnSignatureDocumentation, HELPER_PANE_EX_BTN_OFFSET, HelperPaneHeight } from "@wso2/ui-toolkit"; import { CloseHelperButton, ExpandButton, OpenHelperButton } from "./FloatingButtonIcons"; +import { LineRange } from "@wso2/ballerina-core"; type HelperPaneState = { isOpen: boolean; top: number; left: number; } +export type ChipExpressionBaseComponentProps = { + 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; +} export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentProps) => { const [helperPaneState, setHelperPaneState] = useState({ isOpen: false, top: 0, left: 0 }); @@ -57,8 +81,9 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP 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 (isTrigger) { + if (newValue === '' || isTrigger || isRangeSelection) { setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); } else { setHelperPaneState({ isOpen: false, top: 0, left: 0 }); @@ -90,11 +115,6 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP selection: { anchor: from + value.length } }); - const newDoc = view.state.doc.toString(); - props.onChange(newDoc, from + value.length); - if (options.closeHelperPane) { - setIsTokenUpdateScheduled(true); - } setHelperPaneState(prev => ({ ...prev, isOpen: !options.closeHelperPane })); } @@ -126,32 +146,6 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP })); } - useEffect(() => { - if (!props.value || !viewRef.current) return; - const updateEditorState = async () => { - const currentDoc = viewRef.current!.state.doc.toString(); - if (currentDoc !== props.value) { - viewRef.current!.dispatch({ - changes: { from: 0, to: currentDoc.length, insert: props.value } - }); - } - - if (!isTokenUpdateScheduled) return; - const tokenStream = await expressionEditorRpcManager?.getExpressionTokens( - props.value, - props.fileName, - props.targetLineRange.startLine - ); - setIsTokenUpdateScheduled(false); - if (tokenStream) { - viewRef.current!.dispatch({ - effects: tokensChangeEffect.of(tokenStream) - }); - } - }; - updateEditorState(); - }, [props.value, props.fileName, props.targetLineRange.startLine, isTokenUpdateScheduled]); - useEffect(() => { if (!editorRef.current) return; const startState = EditorState.create({ @@ -185,6 +179,30 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP }; }, []); + 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 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: currentDoc.length, insert: props.value } + }); + } + }; + updateEditorState(); + }, [props.value, props.fileName, props.targetLineRange.startLine, isTokenUpdateScheduled]); + + // this keeps completions ref updated // just don't touch this. useEffect(() => { @@ -213,9 +231,7 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP return (
-
- -
+
{helperPaneState.isOpen && ((pro ref={ref} top={props.top} left={props.left} + onMouseDown={e=> { + e.preventDefault(); + e.stopPropagation(); + }} > {props.getHelperPane( props.value, From 442dc911ed6dbfe4e562b94904ed32ef94c3d0fa Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 18:33:00 +0530 Subject: [PATCH 06/18] fix floating buttons in expanded view --- .../ChipExpressionEditor/CodeUtils.ts | 3 + .../ChipExpressionBaseComponent2.tsx | 112 +++++++++++++----- 2 files changed, 83 insertions(+), 32 deletions(-) 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 009f7f976f..c3333f6904 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 @@ -99,6 +99,9 @@ export function createChip(text: string, type: TokenType, start: number, end: nu } export const chipTheme = EditorView.theme({ + "&": { + backgroundColor: "var(--vscode-input-background)" + }, ".cm-content": { caretColor: "#ffffff" }, diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx index c52d6c6edb..da4cb98604 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -23,11 +23,12 @@ import { useFormContext } from "../../../../../context"; import { buildNeedTokenRefetchListner, buildOnChangeListner, chipPlugin, chipTheme, completionTheme, tokenField, tokensChangeEffect, expressionEditorKeymap, buildCompletionSource, buildHelperPaneKeymap, buildOnFocusListner, CursorInfo, buildOnFocusOutListner } from "../CodeUtils"; import { history } from "@codemirror/commands"; import { autocompletion } from "@codemirror/autocomplete"; -import { ContextMenuContainer, FloatingButtonContainer, FloatingToggleButton } from "../styles"; +import { ContextMenuContainer, FloatingButtonContainer, FloatingToggleButton, ChipEditorContainer } from "../styles"; import { HelperpaneOnChangeOptions } from "../../../../Form/types"; -import { CompletionItem, FnSignatureDocumentation, HELPER_PANE_EX_BTN_OFFSET, HelperPaneHeight } from "@wso2/ui-toolkit"; +import { Codicon, CompletionItem, FnSignatureDocumentation, HELPER_PANE_EX_BTN_OFFSET, HelperPaneHeight } from "@wso2/ui-toolkit"; import { CloseHelperButton, ExpandButton, OpenHelperButton } from "./FloatingButtonIcons"; import { LineRange } from "@wso2/ballerina-core"; +import FXButton from "./FxButton"; type HelperPaneState = { isOpen: boolean; @@ -64,6 +65,7 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP 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); @@ -105,7 +107,7 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP setHelperPaneState(prev => ({ ...prev, isOpen: false })); }); - const onHelperItemSelect = (value: string, options: HelperpaneOnChangeOptions) => { + const onHelperItemSelect = async (value: string, options: HelperpaneOnChangeOptions) => { if (!viewRef.current) return; const view = viewRef.current; const { from, to } = view.state.selection.main; @@ -114,7 +116,9 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP changes: { from, to, insert: value }, selection: { anchor: from + value.length } }); - + if (options.closeHelperPane) { + setIsTokenUpdateScheduled(true); + } setHelperPaneState(prev => ({ ...prev, isOpen: !options.closeHelperPane })); } @@ -166,7 +170,11 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP needTokenRefetchListner, handleChangeListner, handleFocusListner, - handleFocusOutListner + handleFocusOutListner, + ...(props.isInExpandedMode ? [EditorView.theme({ + "&": { height: "100%" }, + ".cm-scroller": { overflow: "auto" } + })] : []) ] }); const view = new EditorView({ @@ -186,6 +194,9 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP 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, @@ -195,7 +206,8 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP if (tokenStream) { viewRef.current!.dispatch({ effects: tokensChangeEffect.of(tokenStream), - changes: { from: 0, to: currentDoc.length, insert: props.value } + changes: { from: 0, to: currentDoc.length, insert: props.value }, + selection: { anchor: currentSelection.anchor, head: currentSelection.head } }); } }; @@ -230,34 +242,70 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP }, [helperPaneState.isOpen]); return ( -
-
- {helperPaneState.isOpen && - - } - - + {props.isExpandedVersion && ( +
+ + + + )} + + {!props.isInExpandedMode && } +
+
+ {helperPaneState.isOpen && + + } + + {!props.isExpandedVersion && + + {helperPaneState.isOpen ? : } + } + {props.onOpenExpandedMode && !props.isInExpandedMode && ( + + + + )} + +
+ + + ); } @@ -279,7 +327,7 @@ export const HelperPane = React.forwardRef((pro ref={ref} top={props.top} left={props.left} - onMouseDown={e=> { + onMouseDown={e => { e.preventDefault(); e.stopPropagation(); }} From 16f6c7a878c4dffc69f82f1c4b91c51f51a349be Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 18:43:56 +0530 Subject: [PATCH 07/18] add function signature handling --- .../ChipExpressionBaseComponent2.tsx | 107 ++++++++---------- .../components/HelperPane.tsx | 54 +++++++++ .../components/HelperPaneToggleButton.tsx | 60 ++++++++++ 3 files changed, 159 insertions(+), 62 deletions(-) create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPane.tsx create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx index da4cb98604..31c7c5adff 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -20,15 +20,31 @@ 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 } from "../CodeUtils"; +import { + buildNeedTokenRefetchListner, + buildOnChangeListner, + chipPlugin, + chipTheme, + completionTheme, + tokenField, + tokensChangeEffect, + expressionEditorKeymap, + buildCompletionSource, + buildHelperPaneKeymap, + buildOnFocusListner, + CursorInfo, + buildOnFocusOutListner +} from "../CodeUtils"; import { history } from "@codemirror/commands"; import { autocompletion } from "@codemirror/autocomplete"; -import { ContextMenuContainer, FloatingButtonContainer, FloatingToggleButton, ChipEditorContainer } from "../styles"; +import { FloatingButtonContainer, FloatingToggleButton, ChipEditorContainer } from "../styles"; import { HelperpaneOnChangeOptions } from "../../../../Form/types"; -import { Codicon, CompletionItem, FnSignatureDocumentation, HELPER_PANE_EX_BTN_OFFSET, HelperPaneHeight } from "@wso2/ui-toolkit"; +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; @@ -108,13 +124,34 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP }); const onHelperItemSelect = async (value: string, options: HelperpaneOnChangeOptions) => { + const newValue = value if (!viewRef.current) return; const view = viewRef.current; const { from, to } = view.state.selection.main; + let finalValue = newValue; + let cursorPosition = from + newValue.length; + + 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: value }, - selection: { anchor: from + value.length } + changes: { from, to, insert: finalValue }, + selection: { anchor: cursorPosition } }); if (options.closeHelperPane) { setIsTokenUpdateScheduled(true); @@ -244,33 +281,11 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP return ( <> {props.isExpandedVersion && ( - + /> )} {!props.isInExpandedMode && } @@ -308,35 +323,3 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP ); } - -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" - )} - - ); -}); 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..188666ba20 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx @@ -0,0 +1,60 @@ +/** + * 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 From 4c4195b6fabd75bf9a4e992cedbae1ba99f8a01f Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 18:48:03 +0530 Subject: [PATCH 08/18] add hack comment for future reference --- .../components/ChipExpressionBaseComponent2.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx index 31c7c5adff..53cc096bf0 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx @@ -132,6 +132,11 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP 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 { From 5f6f587662de5c3a352778de26f829f31190fc01 Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 19:08:17 +0530 Subject: [PATCH 09/18] rename editor component to chipexpressioneditor --- .../components/editors/ExpandedEditor/modes/ExpressionMode.tsx | 2 +- .../src/components/editors/ExpressionField.tsx | 2 +- ...hipExpressionBaseComponent2.tsx => ChipExpressionEditor.tsx} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/{ChipExpressionBaseComponent2.tsx => ChipExpressionEditor.tsx} (100%) 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 4ee297c265..88be2d1fd4 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 { ChipExpressionBaseComponent2 } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2"; +import { ChipExpressionBaseComponent2 } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; const ExpressionContainer = styled.div` width: 100%; 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 91037effee..34c9b07258 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx @@ -30,7 +30,7 @@ import TextModeEditor from './MultiModeExpressionEditor/TextExpressionEditor/Tex import { InputMode } from './MultiModeExpressionEditor/ChipExpressionEditor/types'; import { LineRange } from '@wso2/ballerina-core/lib/interfaces/common'; import { HelperpaneOnChangeOptions } from '../Form/types'; -import { ChipExpressionBaseComponent2 } from './MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2'; +import { ChipExpressionBaseComponent2 } from './MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor'; export interface ExpressionField { inputMode: InputMode; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx similarity index 100% rename from workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionBaseComponent2.tsx rename to workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx From b930fbc0560dbd3a8d229760b6ca862fa66af0c4 Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 19:15:29 +0530 Subject: [PATCH 10/18] add right padding to the editor to make space for the floating buttons --- .../ChipExpressionEditor/CodeUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 c3333f6904..8b417aaeaf 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 @@ -103,7 +103,8 @@ export const chipTheme = EditorView.theme({ backgroundColor: "var(--vscode-input-background)" }, ".cm-content": { - caretColor: "#ffffff" + caretColor: "#ffffff", + paddingRight: "40px" }, "&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor": { borderLeftColor: "#ffffff" From fa52286a7e146925a8a6402f553c7c96304531bc Mon Sep 17 00:00:00 2001 From: Senith Uthsara Date: Fri, 14 Nov 2025 19:39:51 +0530 Subject: [PATCH 11/18] Address PR comments --- .../editors/ExpandedEditor/modes/ExpressionMode.tsx | 4 ++-- .../src/components/editors/ExpressionField.tsx | 4 ++-- .../ChipExpressionEditor/CodeUtils.ts | 4 ++-- .../components/ChipExpressionEditor.tsx | 8 ++++---- .../components/HelperPaneToggleButton.tsx | 4 ++++ 5 files changed, 14 insertions(+), 10 deletions(-) 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 88be2d1fd4..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 { ChipExpressionBaseComponent2 } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; +import { ChipExpressionEditorComponent } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; const ExpressionContainer = styled.div` width: 100%; @@ -48,7 +48,7 @@ export const ExpressionMode: React.FC = ({ return ( - = ({ } return ( - CompletionItem[]) => }; }; -export const buildHelperPaneKeymap = (isHelperPaneOpen: boolean, onClose: () => void) => { +export const buildHelperPaneKeymap = (getIsHelperPaneOpen: () => boolean, onClose: () => void) => { return [ { key: "Escape", run: (_view) => { - if (!isHelperPaneOpen) return false; + 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 index 53cc096bf0..5beed0bf4f 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 @@ -51,7 +51,7 @@ type HelperPaneState = { top: number; left: number; } -export type ChipExpressionBaseComponentProps = { +export type ChipExpressionEditorComponentProps = { onTokenRemove?: (token: string) => void; onTokenClick?: (token: string) => void; isExpandedVersion: boolean; @@ -70,13 +70,13 @@ export type ChipExpressionBaseComponentProps = { currentArgIndex: number; documentation?: FnSignatureDocumentation; }>; - targetLineRange?: LineRange; + targetLineRange: LineRange; onOpenExpandedMode?: () => void; onRemove?: () => void; isInExpandedMode?: boolean; } -export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentProps) => { +export const ChipExpressionEditorComponent = (props: ChipExpressionEditorComponentProps) => { const [helperPaneState, setHelperPaneState] = useState({ isOpen: false, top: 0, left: 0 }); const editorRef = useRef(null); @@ -119,7 +119,7 @@ export const ChipExpressionBaseComponent2 = (props: ChipExpressionBaseComponentP const completionSource = buildCompletionSource(() => completionsRef.current); - const helperPaneKeymap = buildHelperPaneKeymap(helperPaneState.isOpen, () => { + const helperPaneKeymap = buildHelperPaneKeymap(() => helperPaneState.isOpen, () => { setHelperPaneState(prev => ({ ...prev, isOpen: false })); }); 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 index 188666ba20..1efb994b99 100644 --- 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 @@ -28,10 +28,14 @@ export const HelperPaneToggleButton = React.forwardRef { + return (