all
This commit is contained in:
157
src/App.vue
Normal file
157
src/App.vue
Normal 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
161
src/api/completion.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
217
src/components/MonacoEditor.vue
Normal file
217
src/components/MonacoEditor.vue
Normal 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>
|
||||
40
src/components/ThemeToggle.vue
Normal file
40
src/components/ThemeToggle.vue
Normal 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">☀</span>
|
||||
<span v-else class="icon">☾</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
14
src/main.ts
Normal 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
19
src/style.css
Normal 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
30
src/types/completion.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user