diff --git a/src/cloud/components/Editor/index.tsx b/src/cloud/components/Editor/index.tsx index 639530cb0a..bc5b0d8358 100644 --- a/src/cloud/components/Editor/index.tsx +++ b/src/cloud/components/Editor/index.tsx @@ -102,7 +102,8 @@ import { useLocalSnapshot } from '../../lib/stores/localSnapshots' import SyncStatus from '../Topbar/SyncStatus' import { CodeMirrorEditorModeHints, - getModeSuggestions, + getCodeBlockHintContext, + getCodeBlockModeSuggestions, } from '../../lib/editor/CodeMirror' import { scrollEditorToLine } from '../../lib/hooks/editor/docEditor' @@ -361,18 +362,23 @@ const Editor = ({ const cursorsEqual = currentCursor.ch == previousCursor.ch && currentCursor.line == previousCursor.line + const codeBlockHintContext = getCodeBlockHintContext( + currentLine, + cursorColumn + ) if ( !cm.state.completeActive && - // user has started with '```x' and is waiting for options - currentLine.startsWith('```') && - currentLine.length >= 4 && - cursorColumn >= 3 && + codeBlockHintContext != null && !cursorsEqual ) { - const inputWord = currentLine.substring(3) - const modeSuggestions = getModeSuggestions(inputWord) + const modeSuggestions = getCodeBlockModeSuggestions( + codeBlockHintContext.language, + codeBlockHintContext.fenceMarker + ) const isOnlySuggestion = - modeSuggestions.length == 1 && modeSuggestions[0].text == inputWord + codeBlockHintContext.language.length > 0 && + modeSuggestions.length == 1 && + modeSuggestions[0].text == codeBlockHintContext.language if (!isOnlySuggestion) { cm.showHint() } diff --git a/src/cloud/lib/editor/CodeMirror.spec.ts b/src/cloud/lib/editor/CodeMirror.spec.ts new file mode 100644 index 0000000000..8a6388f87a --- /dev/null +++ b/src/cloud/lib/editor/CodeMirror.spec.ts @@ -0,0 +1,75 @@ +jest.mock('codemirror', () => ({ + Pos: (line: number, ch: number) => ({ line, ch }), +})) +jest.mock('codemirror/addon/runmode/runmode', () => undefined) +jest.mock('codemirror/mode/markdown/markdown', () => undefined) +jest.mock('codemirror/mode/javascript/javascript', () => undefined) +jest.mock('codemirror/mode/css/css', () => undefined) +jest.mock('codemirror/mode/diff/diff', () => undefined) +jest.mock('codemirror/lib/codemirror.css', () => undefined) +jest.mock('codemirror/addon/edit/continuelist', () => undefined) +jest.mock('codemirror/keymap/vim', () => undefined) +jest.mock('codemirror/addon/hint/show-hint', () => undefined) +jest.mock('codemirror/addon/hint/show-hint.css', () => undefined) +jest.mock('codemirror/keymap/sublime', () => undefined) +jest.mock('codemirror/keymap/emacs', () => undefined) +jest.mock('codemirror/addon/scroll/scrollpastend', () => undefined) +jest.mock('../../../design/lib/codemirror/util', () => ({ + loadMode: jest.fn(), +})) + +import { + getCodeBlockHintContext, + getCodeBlockModeSuggestions, +} from './CodeMirror' + +describe('CodeMirror code block mode hints', () => { + test('detects backtick and tilde code fences', () => { + expect(getCodeBlockHintContext('```', 3)).toEqual({ + fenceMarker: '```', + language: '', + }) + expect(getCodeBlockHintContext('~~~js', 5)).toEqual({ + fenceMarker: '~~~', + language: 'js', + }) + }) + + test('offers a plain code block option before initial language hints', () => { + const suggestions = getCodeBlockModeSuggestions('', '```') + + expect(suggestions[0].text).toEqual('') + expect(suggestions[0].displayText).toEqual('Plain code block') + expect(suggestions.map((suggestion) => suggestion.text)).toContain('js') + expect(suggestions.map((suggestion) => suggestion.text)).toContain('python') + }) + + test('selected language hint completes the code block', () => { + const jsSuggestion = getCodeBlockModeSuggestions('j', '```').find( + (suggestion) => suggestion.text === 'js' + ) + const cm = { + replaceRange: jest.fn(), + setCursor: jest.fn(), + } + + expect(jsSuggestion).toBeDefined() + jsSuggestion!.hint!( + cm as any, + { + from: { line: 4, ch: 0 }, + to: { line: 4, ch: 4 }, + list: [], + }, + jsSuggestion! + ) + + expect(cm.replaceRange).toHaveBeenCalledWith( + '```js\n\n```', + { line: 4, ch: 0 }, + { line: 4, ch: 4 }, + 'complete' + ) + expect(cm.setCursor).toHaveBeenCalledWith({ line: 5, ch: 0 }) + }) +}) diff --git a/src/cloud/lib/editor/CodeMirror.ts b/src/cloud/lib/editor/CodeMirror.ts index 530b3ffbb5..d5135b08ca 100644 --- a/src/cloud/lib/editor/CodeMirror.ts +++ b/src/cloud/lib/editor/CodeMirror.ts @@ -240,6 +240,51 @@ for (const [key, suggestions] of Object.entries(supportedModeSuggestions)) { }) } +const initialModeSuggestions = [ + 'js', + 'ts', + 'python', + 'shell', + 'css', + 'html', + 'sql', + 'go', + 'rust', + 'java', +] + +function getAllModeSuggestions( + suggestionModes: SuggestionModeType = improvedModeSuggestions +) { + return Object.values(suggestionModes).reduce( + (result, suggestions) => result.concat(suggestions), + [] + ) +} + +export function getInitialModeSuggestions( + suggestionModes: SuggestionModeType = improvedModeSuggestions +): CodeMirror.Hint[] { + const suggestions = getAllModeSuggestions(suggestionModes) + const initialSuggestions: CodeMirror.Hint[] = [] + + initialModeSuggestions.forEach((autocomplete) => { + const suggestion = suggestions.find( + (modeSuggestion) => modeSuggestion.autocomplete === autocomplete + ) + if (suggestion == null) { + return + } + + initialSuggestions.push({ + text: suggestion.autocomplete, + displayText: suggestion.displayText, + }) + }) + + return initialSuggestions +} + export function getModeSuggestions( word: string, suggestionModes: SuggestionModeType = improvedModeSuggestions @@ -283,11 +328,89 @@ export function getModeSuggestions( return [] } +export function getCodeBlockHintContext(line: string, cursorColumn: number) { + const fenceMarker = line.startsWith('```') + ? '```' + : line.startsWith('~~~') + ? '~~~' + : null + if (fenceMarker == null || cursorColumn < fenceMarker.length) { + return null + } + + const language = line.slice(fenceMarker.length).toLowerCase() + if (!/^[\w#+-]*$/.test(language)) { + return null + } + + return { + fenceMarker, + language, + } +} + +function buildCodeBlockHint( + fenceMarker: string, + suggestion: CodeMirror.Hint +): CodeMirror.Hint { + return { + ...suggestion, + hint: (cm, data, selectedSuggestion) => { + const language = selectedSuggestion.text + cm.replaceRange( + `${fenceMarker}${language}\n\n${fenceMarker}`, + data.from, + data.to, + 'complete' + ) + cm.setCursor(CodeMirror.Pos(data.from.line + 1, 0)) + }, + } +} + +export function getCodeBlockModeSuggestions( + language: string, + fenceMarker: string +) { + const modeSuggestions = + language.length === 0 + ? [ + { + text: '', + displayText: 'Plain code block', + }, + ...getInitialModeSuggestions(), + ] + : getModeSuggestions(language) + + return modeSuggestions.map((suggestion) => { + return buildCodeBlockHint(fenceMarker, suggestion) + }) +} + export function CodeMirrorEditorModeHints(cm: CodeMirror.Editor) { return new Promise(function (accept) { setTimeout(function () { const cursor = cm.getCursor(), line = cm.getLine(cursor.line) + + const codeBlockHintContext = getCodeBlockHintContext(line, cursor.ch) + if (codeBlockHintContext != null) { + const suggestions = getCodeBlockModeSuggestions( + codeBlockHintContext.language, + codeBlockHintContext.fenceMarker + ) + if (suggestions.length == 0) { + return accept(null) + } + + return accept({ + list: suggestions, + from: CodeMirror.Pos(cursor.line, 0), + to: CodeMirror.Pos(cursor.line, line.length), + }) + } + let start = cursor.ch let end = cursor.ch while (start && /\w/.test(line.charAt(start - 1))) --start