This commit is contained in:
2026-02-15 15:55:49 +08:00
commit 23decb8687
32 changed files with 3822 additions and 0 deletions

157
src/App.vue Normal file
View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import MonacoEditor from './components/MonacoEditor.vue'
import ThemeToggle from './components/ThemeToggle.vue'
interface LanguageOption {
label: string
value: string
}
const isDark = ref(true)
const theme = computed(() => (isDark.value ? 'vs-dark' : 'vs'))
const languageOptions: LanguageOption[] = [
{ label: 'Go', value: 'go' },
{ label: 'JavaScript', value: 'javascript' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'Python', value: 'python' },
{ label: 'Java', value: 'java' },
{ label: 'JSON', value: 'json' },
{ label: 'YAML', value: 'yaml' },
{ label: 'Markdown', value: 'markdown' },
{ label: 'HTML', value: 'html' },
{ label: 'CSS', value: 'css' },
{ label: 'SQL', value: 'sql' },
{ label: 'Shell', value: 'shell' },
]
const initialCodeByLanguage: Record<string, string> = {
go: ['package main', '', 'import "fmt"', '', 'func main() {', '\tfmt.Println("Hello, World!")', '}'].join('\n'),
javascript: ['function hello() {', ' console.log("Hello, World!")', '}', '', 'hello();'].join('\n'),
typescript: [
'function greet(name: string): void {',
' console.log(`Hello, ${name}`)',
'}',
'',
'greet("World")',
].join('\n'),
python: ['def greet(name: str) -> None:', ' print(f"Hello, {name}")', '', 'greet("World")'].join('\n'),
java: [
'public class Main {',
' public static void main(String[] args) {',
' System.out.println("Hello, World!");',
' }',
'}',
].join('\n'),
json: ['{', ' "name": "monica-editor",', ' "version": "1.0.0"', '}'].join('\n'),
yaml: ['name: monica-editor', 'version: 1.0.0', 'enabled: true'].join('\n'),
markdown: ['# Monica Editor', '', '- Multi-language highlighting', '- Go completion backend'].join('\n'),
html: ['<!doctype html>', '<html>', ' <body>', ' <h1>Hello, World!</h1>', ' </body>', '</html>'].join('\n'),
css: ['body {', ' margin: 0;', ' font-family: sans-serif;', '}'].join('\n'),
sql: ['SELECT id, name', 'FROM users', 'WHERE active = true', 'ORDER BY id DESC;'].join('\n'),
shell: ['#!/usr/bin/env bash', 'echo "Hello, World!"'].join('\n'),
}
const selectedLanguage = ref<string>('go')
const codeByLanguage = ref<Record<string, string>>({ ...initialCodeByLanguage })
const currentCode = computed<string>({
get() {
return codeByLanguage.value[selectedLanguage.value] ?? ''
},
set(value: string) {
codeByLanguage.value[selectedLanguage.value] = value
},
})
function toggleTheme() {
isDark.value = !isDark.value
}
</script>
<template>
<div class="app" :class="{ dark: isDark, light: !isDark }">
<header class="toolbar">
<span class="title">Monica Editor</span>
<div class="toolbar-controls">
<label class="language-label" for="language-select">Language</label>
<select id="language-select" v-model="selectedLanguage" class="language-select">
<option v-for="option in languageOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<ThemeToggle :isDark="isDark" @toggle="toggleTheme" />
</div>
</header>
<main class="editor-area">
<MonacoEditor v-model="currentCode" :language="selectedLanguage" :theme="theme" />
</main>
</div>
</template>
<style scoped>
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.app.dark {
background: #1e1e1e;
color: #cccccc;
}
.app.light {
background: #ffffff;
color: #333333;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
height: 48px;
flex-shrink: 0;
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
}
.toolbar-controls {
display: flex;
align-items: center;
gap: 10px;
}
.language-label {
font-size: 13px;
opacity: 0.8;
}
.language-select {
min-width: 148px;
height: 30px;
border-radius: 6px;
border: 1px solid rgba(128, 128, 128, 0.35);
padding: 0 10px;
background: transparent;
color: inherit;
outline: none;
}
.language-select:focus {
border-color: #2f81f7;
}
.title {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.5px;
}
.editor-area {
flex: 1;
overflow: hidden;
}
</style>

161
src/api/completion.ts Normal file
View File

@@ -0,0 +1,161 @@
import type { CompletionRequest, CompletionResponse } from '../types/completion'
const COMPLETION_API_URL =
import.meta.env.VITE_COMPLETION_API_URL ??
'http://127.0.0.1:8080/api/v1/completions/go'
const COMPLETION_WS_URL =
import.meta.env.VITE_COMPLETION_WS_URL ??
'ws://127.0.0.1:8080/ws/completions/go'
const WS_TIMEOUT_MS = 1800
interface WSCompletionResponse extends CompletionResponse {
id: string
error?: string
}
interface PendingRequest {
resolve: (value: CompletionResponse) => void
timer: number
}
let ws: WebSocket | null = null
let wsConnecting: Promise<WebSocket> | null = null
let nextRequestID = 0
const pending = new Map<string, PendingRequest>()
export async function fetchCompletions(
request: CompletionRequest,
): Promise<CompletionResponse> {
try {
const socket = await getWebSocket()
if (socket.readyState === WebSocket.OPEN) {
return await fetchCompletionsByWS(socket, request)
}
} catch {
// Fallback to HTTP below.
}
return await fetchCompletionsByHTTP(request)
}
async function fetchCompletionsByHTTP(
request: CompletionRequest,
): Promise<CompletionResponse> {
try {
const response = await fetch(COMPLETION_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
if (!response.ok) {
console.error('Completion API error:', response.status)
return { items: [], isIncomplete: false }
}
return (await response.json()) as CompletionResponse
} catch (error) {
console.error('Completion API request failed:', error)
return { items: [], isIncomplete: false }
}
}
async function fetchCompletionsByWS(
socket: WebSocket,
request: CompletionRequest,
): Promise<CompletionResponse> {
const id = String(++nextRequestID)
return await new Promise<CompletionResponse>((resolve) => {
const timer = window.setTimeout(async () => {
pending.delete(id)
resolve(await fetchCompletionsByHTTP(request))
}, WS_TIMEOUT_MS)
pending.set(id, { resolve, timer })
try {
socket.send(JSON.stringify({ id, ...request }))
} catch (error) {
window.clearTimeout(timer)
pending.delete(id)
console.error('Completion WS send failed:', error)
void fetchCompletionsByHTTP(request).then(resolve)
}
})
}
async function getWebSocket(): Promise<WebSocket> {
if (ws && ws.readyState === WebSocket.OPEN) {
return ws
}
if (wsConnecting) {
return await wsConnecting
}
wsConnecting = new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(COMPLETION_WS_URL)
let settled = false
socket.onopen = () => {
settled = true
ws = socket
wsConnecting = null
console.info('[completion] websocket connected:', COMPLETION_WS_URL)
resolve(socket)
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
let payload: WSCompletionResponse
try {
payload = JSON.parse(event.data) as WSCompletionResponse
} catch {
return
}
const entry = pending.get(payload.id)
if (!entry) return
window.clearTimeout(entry.timer)
pending.delete(payload.id)
if (payload.error) {
entry.resolve({ items: [], isIncomplete: false })
return
}
entry.resolve({
items: payload.items ?? [],
isIncomplete: payload.isIncomplete ?? false,
})
}
socket.onerror = () => {
if (settled) return
settled = true
wsConnecting = null
console.error('[completion] websocket connect failed:', COMPLETION_WS_URL)
reject(new Error('completion websocket connect failed'))
}
socket.onclose = () => {
console.warn('[completion] websocket closed')
ws = null
wsConnecting = null
flushPending()
}
})
return await wsConnecting
}
function flushPending() {
for (const [id, entry] of pending) {
window.clearTimeout(entry.timer)
pending.delete(id)
entry.resolve({ items: [], isIncomplete: false })
}
}

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue'
import * as monaco from 'monaco-editor'
import { fetchCompletions } from '../api/completion'
import type { CompletionItem } from '../types/completion'
const props = withDefaults(
defineProps<{
modelValue?: string
language?: string
theme?: string
}>(),
{
modelValue: '',
language: 'go',
theme: 'vs-dark',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editorContainer = ref<HTMLDivElement>()
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>()
let completionDisposable: monaco.IDisposable | null = null
/** Map LSP CompletionItemKind to Monaco CompletionItemKind */
function mapKind(kind?: number): monaco.languages.CompletionItemKind {
switch (kind) {
case 2:
return monaco.languages.CompletionItemKind.Method
case 3:
return monaco.languages.CompletionItemKind.Function
case 4:
return monaco.languages.CompletionItemKind.Constructor
case 5:
return monaco.languages.CompletionItemKind.Field
case 6:
return monaco.languages.CompletionItemKind.Variable
case 7:
return monaco.languages.CompletionItemKind.Class
case 8:
return monaco.languages.CompletionItemKind.Interface
case 9:
return monaco.languages.CompletionItemKind.Module
case 10:
return monaco.languages.CompletionItemKind.Property
case 11:
return monaco.languages.CompletionItemKind.Unit
case 12:
return monaco.languages.CompletionItemKind.Value
case 13:
return monaco.languages.CompletionItemKind.Enum
case 14:
return monaco.languages.CompletionItemKind.Keyword
case 15:
return monaco.languages.CompletionItemKind.Snippet
case 16:
return monaco.languages.CompletionItemKind.Color
case 17:
return monaco.languages.CompletionItemKind.File
case 18:
return monaco.languages.CompletionItemKind.Reference
case 19:
return monaco.languages.CompletionItemKind.Folder
case 20:
return monaco.languages.CompletionItemKind.EnumMember
case 21:
return monaco.languages.CompletionItemKind.Constant
case 22:
return monaco.languages.CompletionItemKind.Struct
case 23:
return monaco.languages.CompletionItemKind.Event
case 24:
return monaco.languages.CompletionItemKind.Operator
case 25:
return monaco.languages.CompletionItemKind.TypeParameter
default:
return monaco.languages.CompletionItemKind.Text
}
}
function getDocumentURI(language: string): string {
const name = `main.${language}`
return `file:///${name}`
}
/** Register the code completion provider for the given language */
function registerCompletionProvider(language: string) {
completionDisposable?.dispose()
completionDisposable = null
// Currently only Go completion is wired to backend gopls.
if (language !== 'go') {
return
}
completionDisposable = monaco.languages.registerCompletionItemProvider(language, {
triggerCharacters: ['.', ':', '('],
async provideCompletionItems(model, position) {
if (model.getLanguageId() !== 'go') {
return { suggestions: [] }
}
const code = model.getValue()
const word = model.getWordUntilPosition(position)
const response = await fetchCompletions({
uri: getDocumentURI(language),
text: code,
line: Math.max(position.lineNumber - 1, 0),
character: Math.max(position.column - 1, 0),
})
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
)
const suggestions: monaco.languages.CompletionItem[] = response.items.map(
(item: CompletionItem) => ({
label: item.label,
kind: mapKind(item.kind),
insertText: item.insertText || item.label,
detail: item.detail,
documentation: item.documentation,
range,
}),
)
return { suggestions }
},
})
}
onMounted(() => {
if (!editorContainer.value) return
editor.value = monaco.editor.create(editorContainer.value, {
value: props.modelValue,
language: props.language,
theme: props.theme,
automaticLayout: true,
fontSize: 14,
lineNumbers: 'on',
minimap: { enabled: true },
scrollBeyondLastLine: false,
wordWrap: 'off',
tabSize: 4,
insertSpaces: false, // Go uses tabs
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
suggestOnTriggerCharacters: true,
quickSuggestions: true,
parameterHints: { enabled: true },
})
// Sync content changes back to parent
editor.value.onDidChangeModelContent(() => {
const value = editor.value!.getValue()
emit('update:modelValue', value)
})
// Register completion provider
registerCompletionProvider(props.language)
})
// Watch for theme changes
watch(
() => props.theme,
(newTheme) => {
monaco.editor.setTheme(newTheme)
},
)
// Watch for language changes
watch(
() => props.language,
(newLang) => {
const model = editor.value?.getModel()
if (model) {
monaco.editor.setModelLanguage(model, newLang)
}
registerCompletionProvider(newLang)
},
)
// Watch for external modelValue changes
watch(
() => props.modelValue,
(newValue) => {
if (editor.value && editor.value.getValue() !== newValue) {
editor.value.setValue(newValue)
}
},
)
onBeforeUnmount(() => {
completionDisposable?.dispose()
editor.value?.dispose()
})
</script>
<template>
<div ref="editorContainer" class="editor-container" />
</template>
<style scoped>
.editor-container {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
defineProps<{
isDark: boolean
}>()
const emit = defineEmits<{
toggle: []
}>()
</script>
<template>
<button class="theme-toggle" @click="emit('toggle')" :title="isDark ? 'Switch to light theme' : 'Switch to dark theme'">
<span v-if="isDark" class="icon">&#9728;</span>
<span v-else class="icon">&#9790;</span>
</button>
</template>
<style scoped>
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: transparent;
cursor: pointer;
transition: background 0.2s;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.icon {
font-size: 18px;
line-height: 1;
}
</style>

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
// Monaco Editor worker setup — must run before any monaco import
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
self.MonacoEnvironment = {
getWorker() {
return new editorWorker()
},
}
createApp(App).mount('#app')

19
src/style.css Normal file
View File

@@ -0,0 +1,19 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

30
src/types/completion.ts Normal file
View File

@@ -0,0 +1,30 @@
/** Types for the code completion API */
export interface CompletionRequest {
/** Document URI (file://...) */
uri: string
/** The full text content of the editor */
text: string
/** Cursor line number (0-based) */
line: number
/** Cursor character number (0-based) */
character: number
}
export interface CompletionItem {
/** The text shown in the completion list */
label: string
/** The text to insert when the completion is accepted */
insertText?: string
/** Optional detail text (e.g., type info) */
detail?: string
/** Optional documentation */
documentation?: string
/** LSP CompletionItemKind (number) */
kind?: number
}
export interface CompletionResponse {
items: CompletionItem[]
isIncomplete?: boolean
}