From d587ff65c4673457a75ec4b4f6d49e6a3aa98d39 Mon Sep 17 00:00:00 2001 From: Zoltan Szabo <63643463+zoltanszabo-bitrise@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:21:03 +0100 Subject: [PATCH] feat: use monaco for the script step (#1361) * feat: use monaco for the script step * remove: language selector * Update source/javascripts/components/unified-editor/StepConfigDrawer/components/StepInputGroup.tsx Co-authored-by: Andras Eszes * fix: check for $ char * fix: fine-tune source naming --------- Co-authored-by: Andras Eszes --- .../EditableInput/EditableInput.tsx | 10 ++- .../StepConfigDrawer.stories.tsx | 8 ++ .../components/StepCodeEditor.tsx | 68 +++++++++++++++ .../components/StepInputGroup.tsx | 18 +++- .../tabs/ConfigurationTab.tsx | 6 +- source/javascripts/core/api/SecretApi.ts | 2 +- source/javascripts/hooks/useEnvVars.ts | 6 +- .../javascripts/hooks/useEnvVarsAndSecrets.ts | 18 ++++ .../hooks/useMonacoCompletionProvider.ts | 58 +++++++++++++ source/javascripts/hooks/useMonacoYaml.ts | 31 +++++++ .../pages/YmlPage/components/YmlEditor.tsx | 83 +++---------------- 11 files changed, 221 insertions(+), 87 deletions(-) create mode 100644 source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx create mode 100644 source/javascripts/hooks/useEnvVarsAndSecrets.ts create mode 100644 source/javascripts/hooks/useMonacoCompletionProvider.ts create mode 100644 source/javascripts/hooks/useMonacoYaml.ts diff --git a/source/javascripts/components/EditableInput/EditableInput.tsx b/source/javascripts/components/EditableInput/EditableInput.tsx index ad4cd1663..727f1bfc0 100644 --- a/source/javascripts/components/EditableInput/EditableInput.tsx +++ b/source/javascripts/components/EditableInput/EditableInput.tsx @@ -18,7 +18,8 @@ const defaultValidateFn: Props['validate'] = () => true; const defaultSanitizeFn: Props['sanitize'] = (value) => value; const EditableInput = ({ sanitize = defaultSanitizeFn, validate = defaultValidateFn, onCommit, ...props }: Props) => { - const { value, defaultValue, ...inputProps } = props; + const { size, value, defaultValue, ...inputProps } = props; + const buttonSize = size === 'lg' ? 'md' : 'sm'; // TODO maybe useEditable hook from Chakra UI const [editable, updateEditable] = useReducer>>( @@ -93,6 +94,7 @@ const EditableInput = ({ sanitize = defaultSanitizeFn, validate = defaultValidat return ( - + ) : ( - + ) } /> diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/StepConfigDrawer.stories.tsx b/source/javascripts/components/unified-editor/StepConfigDrawer/StepConfigDrawer.stories.tsx index c7e6ff7a1..89f16b7cf 100644 --- a/source/javascripts/components/unified-editor/StepConfigDrawer/StepConfigDrawer.stories.tsx +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/StepConfigDrawer.stories.tsx @@ -25,3 +25,11 @@ const meta: Meta = { export default meta; export const Default: Story = {}; + +export const Script: Story = { + args: { + isOpen: true, + workflowId: 'steplib-steps', + stepIndex: 5, + }, +}; diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx new file mode 100644 index 000000000..8d6b56145 --- /dev/null +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepCodeEditor.tsx @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Box, Label } from '@bitrise/bitkit'; +import { editor } from 'monaco-editor'; +import { Editor, Monaco } from '@monaco-editor/react'; +import { useEnvVarsAndSecretsCompletionProvider } from '@/hooks/useMonacoCompletionProvider'; + +const EDITOR_OPTIONS = { + fontSize: 13, + fontFamily: 'mono', + roundedSelection: false, + scrollBeyondLastLine: false, + stickyScroll: { enabled: true }, +}; + +type Props = { + label?: string; + value: string; + onChange: (value: string | null) => void; +}; + +const StepCodeEditor = ({ label, value, onChange }: Props) => { + const [monacoInstance, setMonaco] = useState(); + const [editorInstance, setEditor] = useState(); + + useEnvVarsAndSecretsCompletionProvider({ + monaco: monacoInstance, + language: 'shell', + }); + + const updateEditorHeight = useCallback(() => { + if (!editorInstance) { + return; + } + + const contentHeight = editorInstance?.getContentHeight() || 0; + requestAnimationFrame(() => + editorInstance?.layout({ + height: contentHeight, + width: editorInstance?.getLayoutInfo().width, + }), + ); + }, [editorInstance]); + + useEffect(() => { + editorInstance?.onDidChangeModelContent(updateEditorHeight); + updateEditorHeight(); + }, [editorInstance, updateEditorHeight]); + + return ( + + {label && } + onChange(changedValue ?? null)} + onMount={(edtr, mnco) => { + setMonaco(mnco); + setEditor(edtr); + }} + /> + + ); +}; + +export default StepCodeEditor; diff --git a/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepInputGroup.tsx b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepInputGroup.tsx index ee165017d..e33b46eaf 100644 --- a/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepInputGroup.tsx +++ b/source/javascripts/components/unified-editor/StepConfigDrawer/components/StepInputGroup.tsx @@ -1,16 +1,19 @@ import { Fragment } from 'react'; import { Card, Divider, ExpandableCard, Text } from '@bitrise/bitkit'; + import { StepInputVariable } from '@/core/models/Step'; +import StepCodeEditor from './StepCodeEditor'; import StepInput from './StepInput'; import StepSelectInput from './StepSelectInput'; type Props = { title?: string; + stepId?: string; inputs?: StepInputVariable[]; onChange?: (name: string, value: string | null) => void; }; -const StepInputGroup = ({ title, inputs, onChange }: Props) => { +const StepInputGroup = ({ title, stepId, inputs, onChange }: Props) => { const content = ( <> {inputs?.map(({ opts, ...input }, index) => { @@ -18,12 +21,21 @@ const StepInputGroup = ({ title, inputs, onChange }: Props) => { const value = String(input[name] ?? ''); const helper = { summary: opts?.summary, details: opts?.description }; const isSelectInput = opts?.value_options && opts.value_options.length > 0; + const useCodeEditor = stepId === 'script' && name === 'content'; return ( {index > 0 && } - {isSelectInput && ( + {useCodeEditor && ( + onChange?.(name, changedValue ?? null)} + /> + )} + + {!useCodeEditor && isSelectInput && ( { /> )} - {!isSelectInput && ( + {!useCodeEditor && !isSelectInput && ( { /> - {Object.entries(groupStepInputs(mergedValues.inputs) ?? {}).map(([title, inputs]) => { - return ; - })} + {Object.entries(groupStepInputs(mergedValues.inputs) ?? {}).map(([title, inputs]) => ( + + ))} ); }; diff --git a/source/javascripts/core/api/SecretApi.ts b/source/javascripts/core/api/SecretApi.ts index 06f817687..4b0cb160a 100644 --- a/source/javascripts/core/api/SecretApi.ts +++ b/source/javascripts/core/api/SecretApi.ts @@ -72,7 +72,7 @@ function fromLocalResponse(response: LocalSecretItem): Secret { return { key: keyValue[0], value: keyValue[1] as string, - source: 'Bitrise.io', + source: 'Secrets', scope: response.opts?.scope || 'app', isExpand: Boolean(response.opts?.is_expand), isExpose: Boolean(response.opts?.meta?.['bitrise.io']?.is_expose), diff --git a/source/javascripts/hooks/useEnvVars.ts b/source/javascripts/hooks/useEnvVars.ts index c0998ba0d..4da8b4453 100644 --- a/source/javascripts/hooks/useEnvVars.ts +++ b/source/javascripts/hooks/useEnvVars.ts @@ -27,7 +27,7 @@ const useAppLevelEnvVars = () => { const envVarMap = new Map(); s.yml.app?.envs?.forEach((envVarYml) => { - const env = EnvVarService.parseYmlEnvVar(envVarYml, 'app'); + const env = EnvVarService.parseYmlEnvVar(envVarYml, 'Project env vars'); envVarMap.set(env.key, env); }); @@ -42,7 +42,7 @@ const useWorkflowLevelEnvVars = (ids: string[]) => { ids.forEach((workflowId) => { WorkflowService.getWorkflowChain(s.yml.workflows ?? {}, workflowId).forEach((id) => { s.yml.workflows?.[id]?.envs?.forEach((envVarYml) => { - const env = EnvVarService.parseYmlEnvVar(envVarYml, id); + const env = EnvVarService.parseYmlEnvVar(envVarYml, `Workflow: ${id}`); envVarMap.set(env.key, env); }); }); @@ -86,7 +86,7 @@ const useStepLevelEnvVars = (ids: string[], enabled: boolean) => { result.forEach(({ data: step }) => { const source = step?.title || step?.id || step?.cvs || ''; step?.defaultValues?.outputs?.forEach((ymlEnvVar) => { - const env = EnvVarService.parseYmlEnvVar(ymlEnvVar, source); + const env = EnvVarService.parseYmlEnvVar(ymlEnvVar, `Step: ${source}`); envVarMap.set(env.key, env); }); }); diff --git a/source/javascripts/hooks/useEnvVarsAndSecrets.ts b/source/javascripts/hooks/useEnvVarsAndSecrets.ts new file mode 100644 index 000000000..79c281502 --- /dev/null +++ b/source/javascripts/hooks/useEnvVarsAndSecrets.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useWorkflows } from '@/hooks/useWorkflows'; +import useEnvVars from '@/hooks/useEnvVars'; +import { useSecrets } from '@/hooks/useSecrets'; +import { EnvVar } from '@/core/models/EnvVar'; +import WindowUtils from '@/core/utils/WindowUtils'; + +const useEnvVarsAndSecrets = () => { + const appSlug = WindowUtils.appSlug() ?? ''; + const workflows = useWorkflows(); + const ids = Object.keys(workflows); + const { envs } = useEnvVars(ids, true); + const { data: secrets = [] } = useSecrets({ appSlug }); + + return useMemo(() => [...envs, ...secrets].sort((a, b) => a.key.localeCompare(b.key)) as EnvVar[], [envs, secrets]); +}; + +export default useEnvVarsAndSecrets; diff --git a/source/javascripts/hooks/useMonacoCompletionProvider.ts b/source/javascripts/hooks/useMonacoCompletionProvider.ts new file mode 100644 index 000000000..b6755990f --- /dev/null +++ b/source/javascripts/hooks/useMonacoCompletionProvider.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo } from 'react'; +import { languages } from 'monaco-editor'; +import { Monaco } from '@monaco-editor/react'; +import useEnvVarsAndSecrets from '@/hooks/useEnvVarsAndSecrets'; + +type Props = { + monaco: Monaco | undefined; + language: string; +}; + +const useEnvVarsAndSecretsCompletionProvider = ({ monaco, language }: Props) => { + const items = useEnvVarsAndSecrets(); + + const provider: languages.CompletionItemProvider = useMemo( + () => ({ + triggerCharacters: ['$'], + provideCompletionItems: (model, position) => { + const textUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + // Check if the last character is '$' + if (!textUntilPosition.endsWith('$')) { + return { suggestions: [] }; // Return empty suggestions if not triggered by '$' + } + + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + startColumn: word.startColumn, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + }; + + const suggestions: languages.CompletionItem[] = items.map((item) => ({ + label: item.key, + insertText: item.key, + detail: item.source, + kind: languages.CompletionItemKind.Variable, + range, + })); + + return { suggestions }; + }, + }), + [items], + ); + + useEffect(() => { + const disposable = monaco?.languages.registerCompletionItemProvider(language, provider); + return disposable?.dispose; + }, [monaco, language, provider]); +}; + +export { useEnvVarsAndSecretsCompletionProvider }; diff --git a/source/javascripts/hooks/useMonacoYaml.ts b/source/javascripts/hooks/useMonacoYaml.ts new file mode 100644 index 000000000..466e44bb0 --- /dev/null +++ b/source/javascripts/hooks/useMonacoYaml.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { configureMonacoYaml, MonacoYaml } from 'monaco-yaml'; +import { Monaco } from '@monaco-editor/react'; + +const MONACO_YAML_OPTIONS = { + hover: true, + format: true, + validate: true, + completion: true, + enableSchemaRequest: true, + schemas: [ + { + uri: 'https://json.schemastore.org/bitrise.json', + fileMatch: ['*'], + }, + ], +}; + +const useMonacoYaml = (monaco: Monaco | undefined) => { + const [monacoYaml, setMonacoYaml] = useState(); + + useEffect(() => { + if (monaco) { + setMonacoYaml(configureMonacoYaml(monaco, MONACO_YAML_OPTIONS)); + } + }, [monaco]); + + useEffect(() => monacoYaml?.dispose, [monacoYaml]); +}; + +export default useMonacoYaml; diff --git a/source/javascripts/pages/YmlPage/components/YmlEditor.tsx b/source/javascripts/pages/YmlPage/components/YmlEditor.tsx index 199f51ce9..f172b659c 100644 --- a/source/javascripts/pages/YmlPage/components/YmlEditor.tsx +++ b/source/javascripts/pages/YmlPage/components/YmlEditor.tsx @@ -1,16 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { languages } from 'monaco-editor'; -import { configureMonacoYaml, MonacoYaml } from 'monaco-yaml'; +import { useState } from 'react'; import Editor, { Monaco } from '@monaco-editor/react'; import BitriseYmlApi from '@/core/api/BitriseYmlApi'; import { BitriseYml } from '@/core/models/BitriseYml'; import BitriseYmlProvider from '@/contexts/BitriseYmlProvider'; -import { useWorkflows } from '@/hooks/useWorkflows'; -import WindowUtils from '@/core/utils/WindowUtils'; -import useEnvVars from '@/hooks/useEnvVars'; -import { useSecrets } from '@/hooks/useSecrets'; -import { EnvVar } from '@/core/models/EnvVar'; +import { useEnvVarsAndSecretsCompletionProvider } from '@/hooks/useMonacoCompletionProvider'; +import useMonacoYaml from '@/hooks/useMonacoYaml'; const EDITOR_OPTIONS = { roundedSelection: false, @@ -20,20 +15,6 @@ const EDITOR_OPTIONS = { }, }; -const MONACO_YAML_OPTIONS = { - hover: true, - format: true, - validate: true, - completion: true, - enableSchemaRequest: true, - schemas: [ - { - uri: 'https://json.schemastore.org/bitrise.json', - fileMatch: ['*'], - }, - ], -}; - type YmlEditorProps = { ciConfigYml: string; isLoading?: boolean; @@ -42,56 +23,15 @@ type YmlEditorProps = { }; const YmlEditor = (props: YmlEditorProps) => { - const appSlug = WindowUtils.appSlug() ?? ''; const { ciConfigYml, isLoading, readOnly, onEditorChange } = props; - const monacoRef = useRef(); - const [monacoYaml, setMonacoYaml] = useState(); - - const workflows = useWorkflows(); - const ids = Object.keys(workflows); - const { envs } = useEnvVars(ids, true); - const { data: secrets = [] } = useSecrets({ appSlug }); - - const items = useMemo( - () => [...envs, ...secrets].sort((a, b) => a.key.localeCompare(b.key)) as EnvVar[], - [envs, secrets], - ); - - const completionProvider: languages.CompletionItemProvider = useMemo( - () => ({ - triggerCharacters: ['$'], - provideCompletionItems: (model, position) => { - const word = model.getWordUntilPosition(position); - const range = { - startLineNumber: position.lineNumber, - startColumn: word.startColumn, - endLineNumber: position.lineNumber, - endColumn: word.endColumn, - }; - - const suggestions: languages.CompletionItem[] = items.map((item) => ({ - label: item.key, - insertText: item.key, - detail: item.source, - kind: languages.CompletionItemKind.Variable, - range, - })); - - return { suggestions }; - }, - }), - [items], - ); - - useEffect(() => { - return monacoYaml?.dispose; - }, [monacoYaml]); + const [monacoInstance, setMonaco] = useState(); - useEffect(() => { - const disposable = monacoRef.current?.languages.registerCompletionItemProvider('yaml', completionProvider); - return disposable?.dispose; - }, [completionProvider]); + useMonacoYaml(monacoInstance); + useEnvVarsAndSecretsCompletionProvider({ + monaco: monacoInstance, + language: 'yaml', + }); return ( { onChange={onEditorChange} value={isLoading ? 'Loading...' : ciConfigYml} options={{ ...EDITOR_OPTIONS, readOnly: readOnly || isLoading }} - beforeMount={(monaco) => { - monacoRef.current = monaco; - setMonacoYaml(configureMonacoYaml(monaco, MONACO_YAML_OPTIONS)); - }} + beforeMount={setMonaco} /> ); };