1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-23 15:29:42 +02:00

feat(helm): helm actions [r8s-259] (#715)

Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
Ali 2025-05-13 22:15:04 +12:00 committed by GitHub
parent dfa32b6755
commit 4ee349bd6b
117 changed files with 4161 additions and 696 deletions

View file

@ -0,0 +1,206 @@
.root {
--text-cm-default-color: var(--blue-1);
--text-cm-meta-color: var(--black-color);
--text-cm-string-color: var(--red-3);
--text-cm-number-color: var(--green-1);
--text-cm-keyword-color: var(--ui-blue-dark-9);
--text-cm-comment-color: var(--ui-orange-6);
--text-cm-variable-name-color: var(--ui-green-8);
--text-codemirror-color: var(--black-color);
--bg-codemirror-color: var(--white-color);
--bg-codemirror-gutters-color: var(--grey-17);
--bg-codemirror-selected-color: var(--grey-22);
--border-codemirror-cursor-color: var(--black-color);
--bg-tooltip-color: var(--white-color);
--text-tooltip-color: var(--black-color);
}
:global([theme='dark']) .root {
--text-cm-default-color: var(--blue-10);
--text-cm-meta-color: var(--white-color);
--text-cm-string-color: var(--red-5);
--text-cm-number-color: var(--green-2);
--text-cm-keyword-color: var(--ui-purple-6);
--text-codemirror-color: var(--white-color);
--bg-codemirror-color: var(--grey-2);
--bg-codemirror-gutters-color: var(--grey-3);
--bg-codemirror-selected-color: var(--grey-3);
--border-codemirror-cursor-color: var(--white-color);
--bg-tooltip-color: var(--grey-3);
--text-tooltip-color: var(--white-color);
}
:global([theme='highcontrast']) .root {
--text-cm-default-color: var(--blue-9);
--text-cm-meta-color: var(--white-color);
--text-cm-string-color: var(--red-7);
--text-cm-number-color: var(--green-2);
--text-cm-keyword-color: var(--ui-purple-6);
--text-codemirror-color: var(--white-color);
--bg-codemirror-color: var(--black-color);
--bg-codemirror-gutters-color: var(--ui-gray-warm-11);
--bg-codemirror-selected-color: var(--grey-3);
--border-codemirror-cursor-color: var(--white-color);
--bg-tooltip-color: var(--black-color);
--text-tooltip-color: var(--white-color);
}
.root :global(.cm-editor .cm-gutters) {
border-right: 0px;
@apply bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black;
}
.root :global(.cm-merge-b) {
@apply border-0 border-l border-solid border-l-gray-5 th-dark:border-l-gray-7 th-highcontrast:border-l-gray-2;
}
.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) {
text-align: left;
}
.codeEditor :global(.cm-editor),
.codeEditor :global(.cm-editor .cm-scroller) {
border-radius: 8px;
}
/* code mirror merge side-by-side editor */
.root :global(.cm-merge-a),
.root :global(.cm-merge-a .cm-scroller) {
@apply !rounded-r-none;
}
.root :global(.cm-merge-b),
.root :global(.cm-merge-b .cm-scroller) {
@apply !rounded-l-none;
}
/* Search Panel */
/* Ideally we would use a react component for that, but this is the easy solution for onw */
.root :global(.cm-panels.cm-panels-bottom) {
background-color: var(--bg-codemirror-gutters-color);
border-top-color: transparent;
color: var(--text-codemirror-color);
}
.root :global(.cm-button) {
background-image: none;
border-radius: 4px;
gap: 5px;
}
.root :global(.cm-button[name='next']),
.root :global(.cm-button[name='replace']) {
@apply border-blue-8 bg-blue-8 text-white;
@apply hover:border-blue-9 hover:bg-blue-9 hover:text-white;
@apply th-dark:hover:border-blue-7 th-dark:hover:bg-blue-7;
}
.root :global(.cm-button[name='prev']),
.root :global(.cm-button[name='replaceAll']) {
@apply border border-solid;
@apply border-blue-8 bg-blue-2 text-blue-9;
@apply hover:bg-blue-3;
@apply th-dark:border-blue-7 th-dark:bg-gray-10 th-dark:text-blue-3;
@apply th-dark:hover:bg-blue-11;
}
.root :global(.cm-button[name='select']) {
@apply border-gray-5 bg-white text-gray-9;
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10;
/* dark mode */
@apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4;
@apply th-dark:hover:border-gray-6 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-gray-warm-4;
@apply th-highcontrast:border-gray-2 th-highcontrast:bg-black th-highcontrast:text-white;
@apply th-highcontrast:hover:border-gray-6 th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:text-gray-warm-4;
}
.root :global(.cm-search) label {
font-weight: 400;
@apply text-gray-7;
@apply th-dark:text-gray-warm-3;
@apply th-highcontrast:text-white;
}
.root :global(.cm-search) input {
border-radius: 4px;
}
.root :global(.cm-textfield) {
border: 1px solid var(--border-form-control-color);
background-color: var(--bg-inputbox);
color: var(--text-form-control-color);
}
.root :global(.cm-content[contenteditable='true']) {
min-height: 100%;
}
.root :global(.cm-content[aria-readonly='true']) {
@apply bg-gray-3;
@apply th-dark:bg-gray-iron-10;
@apply th-highcontrast:bg-black;
}
.root :global(.cm-textfield) {
border: 1px solid var(--border-form-control-color);
background-color: var(--bg-inputbox);
color: var(--text-form-control-color);
@apply text-xs;
}
.root :global(.cm-button) {
@apply text-xs;
}
.root :global(.cm-panel.cm-search label) {
@apply text-xs;
}
/* Tooltip styles for all themes */
.root :global(.cm-tooltip) {
@apply bg-white border border-solid border-gray-5 shadow-md text-xs rounded h-min;
@apply th-dark:bg-gray-9 th-dark:border-gray-7 th-dark:text-white;
@apply th-highcontrast:bg-black th-highcontrast:border-gray-7 th-highcontrast:text-white;
}
/* Hide the completionInfo tooltip when it's empty */
/* note: I only chose the complicated selector because the simple selector `.cm-tooltip.cm-completionInfo:empty` didn't work */
.root :global(.cm-tooltip.cm-completionInfo:not(:has(*:not(:empty)))) {
display: none;
}
/* Active line gutter styles for all themes */
.root :global(.cm-activeLineGutter) {
@apply bg-inherit;
}
/* Collapsed lines gutter styles for all themes */
.root :global(.cm-editor .cm-collapsedLines) {
/* inherit bg, instead of using styles from library */
background: inherit;
@apply bg-blue-2 th-dark:bg-blue-10 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
}
.root :global(.cm-editor .cm-collapsedLines):hover {
@apply bg-blue-3 th-dark:bg-blue-9 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
}
.root :global(.cm-editor .cm-collapsedLines:before) {
content: '↧ Expand all';
background: var(--bg-tooltip-color);
color: var(--text-tooltip-color);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 1000;
margin-left: 4px;
}
/* override the default content */
.root :global(.cm-editor .cm-collapsedLines:after) {
content: '';
}

View file

@ -0,0 +1,138 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Extension } from '@codemirror/state';
import { CodeEditor } from './CodeEditor';
const mockExtension: Extension = { extension: [] };
vi.mock('yaml-schema', () => ({
// yamlSchema has 5 return values (all extensions)
yamlSchema: () => [
mockExtension,
mockExtension,
mockExtension,
mockExtension,
mockExtension,
],
yamlCompletion: () => () => ({}),
}));
const defaultProps = {
id: 'test-editor',
onChange: vi.fn(),
value: '',
'data-cy': 'test-editor',
};
beforeEach(() => {
vi.clearAllMocks();
});
test('should render with basic props', () => {
render(<CodeEditor {...defaultProps} />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
test('should display placeholder when provided', async () => {
const placeholder = 'Enter your code here';
const { findByText } = render(
<CodeEditor {...defaultProps} textTip={placeholder} />
);
const placeholderText = await findByText(placeholder);
expect(placeholderText).toBeVisible();
});
test('should show copy button and copy content', async () => {
const testValue = 'test content';
const { findByText } = render(
<CodeEditor {...defaultProps} value={testValue} />
);
const mockClipboard = {
writeText: vi.fn(),
};
Object.assign(navigator, {
clipboard: mockClipboard,
});
const copyButton = await findByText('Copy');
expect(copyButton).toBeVisible();
await userEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(testValue);
});
test('should handle read-only mode', async () => {
const { findByRole } = render(<CodeEditor {...defaultProps} readonly />);
const editor = await findByRole('textbox');
// the editor should not editable
await userEvent.type(editor, 'test');
expect(editor).not.toHaveValue('test');
});
test('should show version selector when versions are provided', async () => {
const versions = [1, 2, 3];
const onVersionChange = vi.fn();
const { findByRole } = render(
<CodeEditor
{...defaultProps}
versions={versions}
onVersionChange={onVersionChange}
/>
);
const selector = await findByRole('combobox');
expect(selector).toBeVisible();
});
test('should handle YAML indentation correctly', async () => {
const onChange = vi.fn();
const yamlContent = 'services:';
const { findByRole } = render(
<CodeEditor
{...defaultProps}
value={yamlContent}
onChange={onChange}
type="yaml"
/>
);
const editor = await findByRole('textbox');
await userEvent.type(editor, '{enter}');
await userEvent.keyboard('database:');
await userEvent.keyboard('{enter}');
await userEvent.keyboard('image: nginx');
await userEvent.keyboard('{enter}');
await userEvent.keyboard('name: database');
// Wait for the debounced onChange to be called
setTimeout(() => {
expect(onChange).toHaveBeenCalledWith(
'services:\n database:\n image: nginx\n name: database'
);
// debounce timeout is 300ms, so 500ms is enough
}, 500);
});
test('should apply custom height', async () => {
const customHeight = '300px';
const { findByRole } = render(
<CodeEditor {...defaultProps} height={customHeight} />
);
const editor = (await findByRole('textbox')).parentElement?.parentElement;
expect(editor).toHaveStyle({ height: customHeight });
});
test('should render with file name header when provided', async () => {
const fileName = 'example.yaml';
const testValue = 'file content';
const { findByText } = render(
<CodeEditor {...defaultProps} fileName={fileName} value={testValue} />
);
expect(await findByText(fileName)).toBeInTheDocument();
expect(await findByText(testValue)).toBeInTheDocument();
});

View file

@ -0,0 +1,158 @@
import CodeMirror from '@uiw/react-codemirror';
import { useCallback, useState } from 'react';
import { createTheme } from '@uiw/codemirror-themes';
import { tags as highlightTags } from '@lezer/highlight';
import type { JSONSchema7 } from 'json-schema';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { CopyButton } from '@@/buttons/CopyButton';
import { useDebounce } from '../../hooks/useDebounce';
import { TextTip } from '../Tip/TextTip';
import { StackVersionSelector } from '../StackVersionSelector';
import styles from './CodeEditor.module.css';
import {
useCodeEditorExtensions,
CodeEditorType,
} from './useCodeEditorExtensions';
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
interface Props extends AutomationTestingProps {
id: string;
textTip?: string;
type?: CodeEditorType;
readonly?: boolean;
onChange?: (value: string) => void;
value: string;
height?: string;
versions?: number[];
onVersionChange?: (version: number) => void;
schema?: JSONSchema7;
fileName?: string;
placeholder?: string;
}
export const theme = createTheme({
theme: 'light',
settings: {
background: 'var(--bg-codemirror-color)',
foreground: 'var(--text-codemirror-color)',
caret: 'var(--border-codemirror-cursor-color)',
selection: 'var(--bg-codemirror-selected-color)',
selectionMatch: 'var(--bg-codemirror-selected-color)',
},
styles: [
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
{
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
color: 'var(--text-cm-string-color)',
},
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
{
tag: highlightTags.variableName,
color: 'var(--text-cm-variable-name-color)',
},
],
});
export function CodeEditor({
id,
onChange = () => {},
textTip,
readonly,
value,
versions,
onVersionChange,
height = '500px',
type,
schema,
'data-cy': dataCy,
fileName,
placeholder,
}: Props) {
const [isRollback, setIsRollback] = useState(false);
const extensions = useCodeEditorExtensions(type, schema);
const handleVersionChange = useCallback(
(version: number) => {
if (versions && versions.length > 1) {
setIsRollback(version < versions[0]);
}
onVersionChange?.(version);
},
[onVersionChange, versions]
);
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
return (
<>
<div className="mb-2 flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center">
{!!textTip && <TextTip color="blue">{textTip}</TextTip>}
</div>
{/* the copy button is in the file name header, when fileName is provided */}
{!fileName && (
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
<CopyButton
data-cy={`copy-code-button-${id}`}
fadeDelay={2500}
copyText={value}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
</div>
)}
</div>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
<div className="overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2">
{fileName && (
<FileNameHeaderRow>
<FileNameHeader
fileName={fileName}
copyText={value}
data-cy={`copy-code-button-${id}`}
/>
</FileNameHeaderRow>
)}
<CodeMirror
className={clsx(styles.root, styles.codeEditor)}
theme={theme}
value={debouncedValue}
onChange={debouncedOnChange}
readOnly={readonly || isRollback}
id={id}
extensions={extensions}
height={height}
basicSetup={{
highlightSelectionMatches: false,
autocompletion: !!schema,
}}
data-cy={dataCy}
placeholder={placeholder}
/>
</div>
</>
);
}

View file

@ -0,0 +1,101 @@
import { render } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { DiffViewer } from './DiffViewer';
// Mock CodeMirror
vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: () => <div data-cy="mock-editor" />,
oneDarkHighlightStyle: {},
keymap: {
of: () => ({}),
},
}));
// Mock react-codemirror-merge
vi.mock('react-codemirror-merge', () => {
function CodeMirrorMerge({ children }: { children: React.ReactNode }) {
return <div data-cy="mock-code-mirror-merge">{children}</div>;
}
function Original({ value }: { value: string }) {
return <div data-cy="mock-original">{value}</div>;
}
function Modified({ value }: { value: string }) {
return <div data-cy="mock-modified">{value}</div>;
}
CodeMirrorMerge.Original = Original;
CodeMirrorMerge.Modified = Modified;
return {
__esModule: true,
default: CodeMirrorMerge,
CodeMirrorMerge,
};
});
describe('DiffViewer', () => {
beforeEach(() => {
// Clear any mocks or state before each test
vi.clearAllMocks();
});
it('should render with basic props', () => {
const { getByText } = render(
<DiffViewer
originalCode="Original text"
newCode="New text"
id="test-diff-viewer"
data-cy="test-diff-viewer"
/>
);
// Check if the component renders with the expected content
expect(getByText('Original text')).toBeInTheDocument();
expect(getByText('New text')).toBeInTheDocument();
});
it('should render with file name headers when provided', () => {
const { getByText } = render(
<DiffViewer
originalCode="Original text"
newCode="New text"
id="test-diff-viewer"
data-cy="test-diff-viewer"
fileNames={{
original: 'Original File',
modified: 'Modified File',
}}
/>
);
// Look for elements with the expected class structure
const headerOriginal = getByText('Original File');
const headerModified = getByText('Modified File');
expect(headerOriginal).toBeInTheDocument();
expect(headerModified).toBeInTheDocument();
});
it('should apply custom height when provided', () => {
const customHeight = '800px';
const { container } = render(
<DiffViewer
originalCode="Original text"
newCode="New text"
id="test-diff-viewer"
data-cy="test-diff-viewer"
height={customHeight}
/>
);
// Find the element with the style containing the height
const divWithStyle = container.querySelector('[style*="height"]');
expect(divWithStyle).toBeInTheDocument();
// Check that the style contains the expected height
expect(divWithStyle?.getAttribute('style')).toContain(
`height: ${customHeight}`
);
});
});

View file

@ -0,0 +1,138 @@
import CodeMirrorMerge from 'react-codemirror-merge';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
import styles from './CodeEditor.module.css';
import {
CodeEditorType,
useCodeEditorExtensions,
} from './useCodeEditorExtensions';
import { theme } from './CodeEditor';
const { Original } = CodeMirrorMerge;
const { Modified } = CodeMirrorMerge;
type Props = {
originalCode: string;
newCode: string;
id: string;
type?: CodeEditorType;
placeholder?: string;
height?: string;
fileNames?: {
original: string;
modified: string;
};
className?: string;
} & AutomationTestingProps;
const defaultCollapseUnchanged = {
margin: 10,
minSize: 10,
};
export function DiffViewer({
originalCode,
newCode,
id,
'data-cy': dataCy,
type,
placeholder = 'No values found',
height = '500px',
fileNames,
className,
}: Props) {
const extensions = useCodeEditorExtensions(type);
const hasFileNames = !!fileNames?.original && !!fileNames?.modified;
return (
<div
className={clsx(
'overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2',
className
)}
>
{hasFileNames && (
<DiffFileNameHeaders
originalCopyText={originalCode}
modifiedCopyText={newCode}
originalFileName={fileNames.original}
modifiedFileName={fileNames.modified}
/>
)}
{/* additional div, so that the scroll gutter doesn't overlap with the rounded border, and always show scrollbar, so that the file name headers align */}
<div
style={
{
// tailwind doesn't like dynamic class names, so use a custom css variable for the height
// https://v3.tailwindcss.com/docs/content-configuration#dynamic-class-names
'--editor-min-height': height,
height,
} as React.CSSProperties
}
className="h-full [scrollbar-gutter:stable] overflow-y-scroll"
>
<CodeMirrorMerge
theme={theme}
className={clsx(
styles.root,
// to give similar sizing to CodeEditor
'[&_.cm-content]:!min-h-[var(--editor-min-height)] [&_.cm-gutters]:!min-h-[var(--editor-min-height)] [&_.cm-editor>.cm-scroller]:!min-h-[var(--editor-min-height)]'
)}
id={id}
data-cy={dataCy}
collapseUnchanged={defaultCollapseUnchanged}
>
<Original
value={originalCode}
extensions={extensions}
readOnly
editable={false}
placeholder={placeholder}
/>
<Modified
value={newCode}
extensions={extensions}
readOnly
editable={false}
placeholder={placeholder}
/>
</CodeMirrorMerge>
</div>
</div>
);
}
function DiffFileNameHeaders({
originalCopyText,
modifiedCopyText,
originalFileName,
modifiedFileName,
}: {
originalCopyText: string;
modifiedCopyText: string;
originalFileName: string;
modifiedFileName: string;
}) {
return (
<FileNameHeaderRow>
<div className="w-1/2">
<FileNameHeader
fileName={originalFileName}
copyText={originalCopyText}
data-cy="original"
/>
</div>
<div className="w-px bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-gray-2" />
<div className="flex-1">
<FileNameHeader
fileName={modifiedFileName}
copyText={modifiedCopyText}
data-cy="modified"
/>
</div>
</FileNameHeaderRow>
);
}

View file

@ -0,0 +1,71 @@
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { CopyButton } from '@@/buttons/CopyButton';
type FileNameHeaderProps = {
fileName: string;
copyText: string;
className?: string;
style?: React.CSSProperties;
} & AutomationTestingProps;
/**
* FileNameHeaderRow: Outer container for file name headers (single or multiple columns).
* Use this to wrap one or more <FileNameHeader> components (and optional dividers).
*/
export function FileNameHeaderRow({
children,
className,
style,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
return (
<div
className={clsx(
'flex w-full text-sm text-muted border-0 border-b border-solid border-b-gray-5 th-dark:border-b-gray-7 th-highcontrast:border-b-gray-2 bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black [scrollbar-gutter:stable] overflow-auto',
className
)}
style={style}
>
{children}
</div>
);
}
/**
* FileNameHeader: Renders a file name with a copy button, styled for use above a code editor or diff viewer.
* Should be used inside FileNameHeaderRow.
*/
export function FileNameHeader({
fileName,
copyText,
className = '',
style,
'data-cy': dataCy,
}: FileNameHeaderProps) {
return (
<div
className={clsx(
'w-full overflow-auto flex justify-between items-center gap-x-2 px-4 py-1 text-sm text-muted',
className
)}
style={style}
>
{fileName}
<CopyButton
data-cy={dataCy}
copyText={copyText}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
</div>
);
}

View file

@ -0,0 +1 @@
export * from './CodeEditor';

View file

@ -0,0 +1,91 @@
import { useMemo } from 'react';
import {
StreamLanguage,
LanguageSupport,
syntaxHighlighting,
indentService,
} from '@codemirror/language';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import {
oneDarkHighlightStyle,
keymap,
Extension,
} from '@uiw/react-codemirror';
import type { JSONSchema7 } from 'json-schema';
import { lintKeymap, lintGutter } from '@codemirror/lint';
import { defaultKeymap } from '@codemirror/commands';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { yamlCompletion, yamlSchema } from 'yaml-schema';
import { compact } from 'lodash';
import { lineNumbers } from '@codemirror/view';
export type CodeEditorType = 'yaml' | 'shell' | 'dockerfile';
// Custom indentation service for YAML
const yamlIndentExtension = indentService.of((context, pos) => {
const prevLine = context.lineAt(pos, -1);
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
if (/:\s*$/.test(prevLine.text)) {
return prevIndent + 2;
}
return prevIndent;
});
const dockerFileLanguage = new LanguageSupport(
StreamLanguage.define(dockerFile)
);
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
function yamlLanguage(schema?: JSONSchema7) {
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
return compact([
yaml,
linter,
stateExtensions,
yamlIndentExtension,
syntaxHighlighting(oneDarkHighlightStyle),
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
lineNumbers(),
lintGutter(),
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
// only show completions when a schema is provided
!!schema &&
autocompletion({
icons: false,
activateOnTypingDelay: 300,
selectOnOpen: true,
activateOnTyping: true,
override: [
(ctx) => {
const getCompletions = yamlCompletion();
const completions = getCompletions(ctx);
if (Array.isArray(completions)) {
return null;
}
completions.validFor = /^\w*$/;
return completions;
},
],
}),
]);
}
export function useCodeEditorExtensions(
type?: CodeEditorType,
schema?: JSONSchema7
): Extension[] {
return useMemo(() => {
switch (type) {
case 'dockerfile':
return [dockerFileLanguage];
case 'shell':
return [shellLanguage];
case 'yaml':
return yamlLanguage(schema);
default:
return [];
}
}, [type, schema]);
}