Skip to content

Commit

Permalink
feat: use monaco for the script step (#1361)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* fix: check for $ char

* fix: fine-tune source naming

---------

Co-authored-by: Andras Eszes <[email protected]>
  • Loading branch information
zoltanszabo-bitrise and AndrasEszes authored Dec 11, 2024
1 parent bd66e62 commit d587ff6
Show file tree
Hide file tree
Showing 11 changed files with 221 additions and 87 deletions.
10 changes: 6 additions & 4 deletions source/javascripts/components/EditableInput/EditableInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Reducer<State, Partial<State>>>(
Expand Down Expand Up @@ -93,6 +94,7 @@ const EditableInput = ({ sanitize = defaultSanitizeFn, validate = defaultValidat
return (
<Input
{...inputProps}
size={size}
value={editable.value}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand All @@ -104,16 +106,16 @@ const EditableInput = ({ sanitize = defaultSanitizeFn, validate = defaultValidat
editable.isEditing ? (
<ButtonGroup justifyContent="center" spacing="0" m="4">
<ControlButton
size="md"
size={buttonSize}
iconName="Check"
aria-label="Change"
isDisabled={editable.validationResult !== true}
onClick={handleCommit}
/>
<ControlButton size="md" aria-label="Cancel" iconName="Cross" onClick={handleCancel} />
<ControlButton size={buttonSize} aria-label="Cancel" iconName="Cross" onClick={handleCancel} />
</ButtonGroup>
) : (
<ControlButton m="4" size="md" aria-label="Edit" iconName="Pencil" onClick={handleEdit} />
<ControlButton m="4" size={buttonSize} aria-label="Edit" iconName="Pencil" onClick={handleEdit} />
)
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@ const meta: Meta<typeof StepConfigDrawer> = {
export default meta;

export const Default: Story = {};

export const Script: Story = {
args: {
isOpen: true,
workflowId: 'steplib-steps',
stepIndex: 5,
},
};
Original file line number Diff line number Diff line change
@@ -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<Monaco>();
const [editorInstance, setEditor] = useState<editor.IStandaloneCodeEditor>();

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 (
<Box display="flex" flexDir="column" gap="8px">
{label && <Label>{label}</Label>}
<Editor
height="initial"
theme="vs-dark"
defaultValue={value}
options={EDITOR_OPTIONS}
defaultLanguage="shell"
onChange={(changedValue) => onChange(changedValue ?? null)}
onMount={(edtr, mnco) => {
setMonaco(mnco);
setEditor(edtr);
}}
/>
</Box>
);
};

export default StepCodeEditor;
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
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) => {
const name = Object.keys(input)[0];
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 (
<Fragment key={name}>
{index > 0 && <Divider my={24} />}

{isSelectInput && (
{useCodeEditor && (
<StepCodeEditor
value={value}
label={opts?.title}
onChange={(changedValue) => onChange?.(name, changedValue ?? null)}
/>
)}

{!useCodeEditor && isSelectInput && (
<StepSelectInput
helper={helper}
label={opts?.title}
Expand All @@ -35,7 +47,7 @@ const StepInputGroup = ({ title, inputs, onChange }: Props) => {
/>
)}

{!isSelectInput && (
{!useCodeEditor && !isSelectInput && (
<StepInput
helper={helper}
label={opts?.title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ const ConfigurationTab = () => {
/>
</ExpandableCard>

{Object.entries(groupStepInputs(mergedValues.inputs) ?? {}).map(([title, inputs]) => {
return <StepInputGroup key={title} title={title} inputs={inputs} onChange={onInputValueChange} />;
})}
{Object.entries(groupStepInputs(mergedValues.inputs) ?? {}).map(([title, inputs]) => (
<StepInputGroup key={title} stepId={data?.id} title={title} inputs={inputs} onChange={onInputValueChange} />
))}
</Box>
);
};
Expand Down
2 changes: 1 addition & 1 deletion source/javascripts/core/api/SecretApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions source/javascripts/hooks/useEnvVars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const useAppLevelEnvVars = () => {
const envVarMap = new Map<string, EnvVar>();

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);
});

Expand All @@ -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);
});
});
Expand Down Expand Up @@ -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);
});
});
Expand Down
18 changes: 18 additions & 0 deletions source/javascripts/hooks/useEnvVarsAndSecrets.ts
Original file line number Diff line number Diff line change
@@ -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;
58 changes: 58 additions & 0 deletions source/javascripts/hooks/useMonacoCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -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 };
31 changes: 31 additions & 0 deletions source/javascripts/hooks/useMonacoYaml.ts
Original file line number Diff line number Diff line change
@@ -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<MonacoYaml>();

useEffect(() => {
if (monaco) {
setMonacoYaml(configureMonacoYaml(monaco, MONACO_YAML_OPTIONS));
}
}, [monaco]);

useEffect(() => monacoYaml?.dispose, [monacoYaml]);
};

export default useMonacoYaml;
Loading

0 comments on commit d587ff6

Please sign in to comment.