Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions src/cloud/components/Editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
}
Expand Down
75 changes: 75 additions & 0 deletions src/cloud/lib/editor/CodeMirror.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
123 changes: 123 additions & 0 deletions src/cloud/lib/editor/CodeMirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down