all
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm create:*)",
|
||||
"Bash(yes:*)",
|
||||
"Bash(npx --yes create-vite@latest:*)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npx vue-tsc:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(npx vite build)"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is a Vite + Vue 3 + TypeScript frontend app.
|
||||
|
||||
- `src/`: application source code
|
||||
- `src/components/`: Vue UI components (for example, `MonacoEditor.vue`, `ThemeToggle.vue`)
|
||||
- `src/api/`: HTTP client logic (`completion.ts`)
|
||||
- `src/types/`: shared TypeScript interfaces for API payloads
|
||||
- `public/`: static assets served as-is
|
||||
- `dist/`: production build output (generated)
|
||||
- `backend/`: placeholder folder; no active backend code is currently committed
|
||||
|
||||
Use the `@` alias for imports from `src` (configured in `vite.config.ts` and `tsconfig.app.json`).
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `npm install`: install dependencies.
|
||||
- `npm run dev`: start local development server with HMR.
|
||||
- `npm run build`: run type checks (`vue-tsc -b`) and build production assets.
|
||||
- `npm run preview`: serve the built app locally for verification.
|
||||
|
||||
Current package scripts do not include linting or unit test commands.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Use Vue 3 Composition API with `<script setup lang="ts">`.
|
||||
- Use TypeScript strict mode patterns; avoid `any` unless justified.
|
||||
- Use 2-space indentation and single quotes, matching existing files.
|
||||
- Name components in PascalCase (e.g., `MonacoEditor.vue`).
|
||||
- Name composables/helpers and API modules in camelCase (e.g., `fetchCompletions`).
|
||||
- Keep shared interfaces in `src/types/*` and colocate feature-specific logic where used.
|
||||
|
||||
## Testing Guidelines
|
||||
No automated test framework is configured yet in `package.json`.
|
||||
|
||||
When adding tests, prefer Vitest + Vue Test Utils and place files as:
|
||||
- `src/**/__tests__/*.spec.ts`
|
||||
|
||||
Minimum verification for every change:
|
||||
1. `npm run build` succeeds.
|
||||
2. `npm run dev` runs without console/runtime errors for the changed flow.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Git history is not available in this workspace snapshot, so no existing commit pattern can be inferred. Use Conventional Commits going forward:
|
||||
- `feat: add completion request timeout`
|
||||
- `fix: handle empty completion response`
|
||||
|
||||
PRs should include:
|
||||
1. Clear summary of user-visible and technical changes.
|
||||
2. Linked issue/task ID (if applicable).
|
||||
3. Validation steps and commands run.
|
||||
4. Screenshots or short recordings for UI changes.
|
||||
14
backend/.claude/settings.local.json
Normal file
14
backend/.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(gopls version:*)",
|
||||
"Bash(go version:*)",
|
||||
"Bash(go env:*)",
|
||||
"Bash(gopls help:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:microsoft.github.io)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
88
backend/README.md
Normal file
88
backend/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Monaco Go Completion Backend
|
||||
|
||||
Gin + gopls(JSON-RPC/LSP over stdio) 实现的 Go 代码补全后端。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
默认监听 `http://localhost:8080`。
|
||||
|
||||
## 环境变量
|
||||
|
||||
- `PORT`:HTTP 端口,默认 `8080`
|
||||
- `GOPLS_PATH`:`gopls` 可执行文件路径,默认 `gopls`
|
||||
- `WORKSPACE_DIR`:gopls 工作目录,默认当前目录
|
||||
- `CORS_ALLOW_ORIGIN`:CORS 允许来源,默认 `*`
|
||||
|
||||
## API
|
||||
|
||||
### 健康检查
|
||||
|
||||
- `GET /health`
|
||||
|
||||
### Go 补全
|
||||
|
||||
- `POST /api/v1/completions/go`
|
||||
- `Content-Type: application/json`
|
||||
|
||||
### Go 补全(WebSocket)
|
||||
|
||||
- `GET /ws/completions/go`
|
||||
- 客户端发送:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"uri": "file:///main.go",
|
||||
"text": "package main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Pri\n}",
|
||||
"line": 5,
|
||||
"character": 11
|
||||
}
|
||||
```
|
||||
|
||||
- 服务端返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"items": [{ "label": "Println" }],
|
||||
"isIncomplete": false
|
||||
}
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"uri": "file:///C:/Users/meowr/Desktop/bishe/monica_editor_with_code_completion/backend/playground.go",
|
||||
"text": "package main\n\nimport \"fmt\"\n\nfunc main() {\n fmt.Pri\n}",
|
||||
"line": 5,
|
||||
"character": 11
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `uri` 建议使用绝对 `file://` URI(与 `WORKSPACE_DIR` 在同一工作区最稳定)。
|
||||
- 也支持 `file:///main.go` 这类相对根路径 URI,后端会自动映射到 `WORKSPACE_DIR` 下。
|
||||
- `line` / `character` 为 0-based。
|
||||
- 每次请求都要传前端当前全文 `text`。
|
||||
|
||||
响应体:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"label": "Println",
|
||||
"kind": 3,
|
||||
"detail": "func(a ...any) (n int, err error)",
|
||||
"documentation": "Println formats using the default formats..."
|
||||
}
|
||||
],
|
||||
"isIncomplete": false
|
||||
}
|
||||
```
|
||||
105
backend/cmd/server/main.go
Normal file
105
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"monica-go-completion-backend/internal/api"
|
||||
"monica-go-completion-backend/internal/completion"
|
||||
"monica-go-completion-backend/internal/lsp"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Port string
|
||||
GoplsPath string
|
||||
WorkspaceDir string
|
||||
AllowOrigin string
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
lspClient, err := lsp.NewClient(context.Background(), cfg.GoplsPath, cfg.WorkspaceDir)
|
||||
if err != nil {
|
||||
log.Fatalf("create gopls client failed: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = lspClient.Close()
|
||||
}()
|
||||
|
||||
completionService := completion.NewService(lspClient)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Logger())
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(corsMiddleware(cfg.AllowOrigin))
|
||||
api.RegisterRoutes(router, completionService)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("completion backend listening on :%s", cfg.Port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("http shutdown failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
cwd = "."
|
||||
}
|
||||
return config{
|
||||
Port: getenv("PORT", "8080"),
|
||||
GoplsPath: getenv("GOPLS_PATH", "gopls"),
|
||||
WorkspaceDir: getenv("WORKSPACE_DIR", cwd),
|
||||
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"),
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func corsMiddleware(allowOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", allowOrigin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
42
backend/go.mod
Normal file
42
backend/go.mod
Normal file
@@ -0,0 +1,42 @@
|
||||
module monica-go-completion-backend
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
90
backend/go.sum
Normal file
90
backend/go.sum
Normal file
@@ -0,0 +1,90 @@
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
43
backend/internal/api/handler.go
Normal file
43
backend/internal/api/handler.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"monica-go-completion-backend/internal/completion"
|
||||
)
|
||||
|
||||
type CompletionService interface {
|
||||
Complete(ctx context.Context, req completion.Request) (completion.Response, error)
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *gin.Engine, service CompletionService) {
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
registerWSRoutes(router, service)
|
||||
|
||||
router.POST("/api/v1/completions/go", func(c *gin.Context) {
|
||||
var req completion.Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := service.Complete(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, completion.ErrInvalidRequest) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "completion failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
}
|
||||
171
backend/internal/api/handler_test.go
Normal file
171
backend/internal/api/handler_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"monica-go-completion-backend/internal/completion"
|
||||
)
|
||||
|
||||
type fakeCompletionService struct {
|
||||
resp completion.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCompletionService) Complete(_ context.Context, _ completion.Request) (completion.Response, error) {
|
||||
if f.err != nil {
|
||||
return completion.Response{}, f.err
|
||||
}
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
func TestRegisterRoutesCompletionSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
RegisterRoutes(r, &fakeCompletionService{
|
||||
resp: completion.Response{Items: []completion.Item{{Label: "Println"}}, IsIncomplete: true},
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"uri": "file:///main.go",
|
||||
"text": "package main",
|
||||
"line": 0,
|
||||
"character": 0,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Items []completion.Item `json:"items"`
|
||||
IsIncomplete bool `json:"isIncomplete"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(got.Items) != 1 || got.Items[0].Label != "Println" || !got.IsIncomplete {
|
||||
t.Fatalf("unexpected response body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutesCompletionBadJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
RegisterRoutes(r, &fakeCompletionService{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader([]byte("not-json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutesCompletionValidationError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
rv := &fakeCompletionService{err: completion.ErrInvalidRequest}
|
||||
RegisterRoutes(r, rv)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"uri": "",
|
||||
"text": "",
|
||||
"line": -1,
|
||||
"character": 0,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutesCompletionServerError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
RegisterRoutes(r, &fakeCompletionService{err: errors.New("boom")})
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"uri": "file:///main.go",
|
||||
"text": "package main",
|
||||
"line": 0,
|
||||
"character": 0,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/completions/go", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutesCompletionWebSocketSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
RegisterRoutes(r, &fakeCompletionService{
|
||||
resp: completion.Response{Items: []completion.Item{{Label: "Println"}}, IsIncomplete: false},
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/completions/go"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("websocket dial failed: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
req := map[string]any{
|
||||
"id": "1",
|
||||
"uri": "file:///main.go",
|
||||
"text": "package main",
|
||||
"line": 0,
|
||||
"character": 0,
|
||||
}
|
||||
if err := conn.WriteJSON(req); err != nil {
|
||||
t.Fatalf("WriteJSON() failed: %v", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Items []completion.Item `json:"items"`
|
||||
IsIncomplete bool `json:"isIncomplete"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := conn.ReadJSON(&resp); err != nil {
|
||||
t.Fatalf("ReadJSON() failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
t.Fatalf("unexpected websocket error: %s", resp.Error)
|
||||
}
|
||||
if resp.ID != "1" {
|
||||
t.Fatalf("unexpected id: %s", resp.ID)
|
||||
}
|
||||
if len(resp.Items) != 1 || resp.Items[0].Label != "Println" {
|
||||
t.Fatalf("unexpected items: %+v", resp.Items)
|
||||
}
|
||||
}
|
||||
101
backend/internal/api/ws_handler.go
Normal file
101
backend/internal/api/ws_handler.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"monica-go-completion-backend/internal/completion"
|
||||
)
|
||||
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(_ *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type wsCompletionRequest struct {
|
||||
ID string `json:"id"`
|
||||
URI string `json:"uri"`
|
||||
Text string `json:"text"`
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
}
|
||||
|
||||
type wsCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Items []completion.Item `json:"items,omitempty"`
|
||||
IsIncomplete bool `json:"isIncomplete,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func registerWSRoutes(router *gin.Engine, service CompletionService) {
|
||||
router.GET("/ws/completions/go", func(c *gin.Context) {
|
||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var writeMu sync.Mutex
|
||||
|
||||
for {
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var req wsCompletionRequest
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
sendWSResponse(conn, &writeMu, wsCompletionResponse{
|
||||
ID: "",
|
||||
Error: "invalid JSON payload",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
go func(r wsCompletionRequest) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := service.Complete(ctx, completion.Request{
|
||||
URI: r.URI,
|
||||
Text: r.Text,
|
||||
Line: r.Line,
|
||||
Character: r.Character,
|
||||
})
|
||||
if err != nil {
|
||||
msg := "completion failed"
|
||||
if errors.Is(err, completion.ErrInvalidRequest) {
|
||||
msg = err.Error()
|
||||
}
|
||||
sendWSResponse(conn, &writeMu, wsCompletionResponse{
|
||||
ID: r.ID,
|
||||
Error: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sendWSResponse(conn, &writeMu, wsCompletionResponse{
|
||||
ID: r.ID,
|
||||
Items: resp.Items,
|
||||
IsIncomplete: resp.IsIncomplete,
|
||||
})
|
||||
}(req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sendWSResponse(conn *websocket.Conn, writeMu *sync.Mutex, resp wsCompletionResponse) {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
_ = conn.WriteJSON(resp)
|
||||
}
|
||||
95
backend/internal/completion/service.go
Normal file
95
backend/internal/completion/service.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrInvalidRequest = errors.New("invalid completion request")
|
||||
|
||||
type Request struct {
|
||||
URI string `json:"uri"`
|
||||
Text string `json:"text"`
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Label string `json:"label"`
|
||||
Kind int `json:"kind,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Documentation string `json:"documentation,omitempty"`
|
||||
InsertText string `json:"insertText,omitempty"`
|
||||
SortText string `json:"sortText,omitempty"`
|
||||
FilterText string `json:"filterText,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Items []Item `json:"items"`
|
||||
IsIncomplete bool `json:"isIncomplete"`
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
DidOpen(ctx context.Context, uri, text string, version int) error
|
||||
DidChange(ctx context.Context, uri, text string, version int) error
|
||||
Completion(ctx context.Context, uri string, line, character int) (Response, error)
|
||||
}
|
||||
|
||||
type documentState struct {
|
||||
version int
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
client Client
|
||||
|
||||
mu sync.Mutex
|
||||
documents map[string]*documentState
|
||||
}
|
||||
|
||||
func NewService(client Client) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
documents: make(map[string]*documentState),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Complete(ctx context.Context, req Request) (Response, error) {
|
||||
if err := validateRequest(req); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
state, opened := s.documents[req.URI]
|
||||
if !opened {
|
||||
if err := s.client.DidOpen(ctx, req.URI, req.Text, 1); err != nil {
|
||||
return Response{}, fmt.Errorf("didOpen failed: %w", err)
|
||||
}
|
||||
s.documents[req.URI] = &documentState{version: 1}
|
||||
} else {
|
||||
nextVersion := state.version + 1
|
||||
if err := s.client.DidChange(ctx, req.URI, req.Text, nextVersion); err != nil {
|
||||
return Response{}, fmt.Errorf("didChange failed: %w", err)
|
||||
}
|
||||
state.version = nextVersion
|
||||
}
|
||||
|
||||
resp, err := s.client.Completion(ctx, req.URI, req.Line, req.Character)
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("completion failed: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func validateRequest(req Request) error {
|
||||
if req.URI == "" {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
if req.Line < 0 || req.Character < 0 {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
144
backend/internal/completion/service_test.go
Normal file
144
backend/internal/completion/service_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeLSPClient struct {
|
||||
openCalls []openCall
|
||||
changeCalls []changeCall
|
||||
completionCalls []completionCall
|
||||
completionResp Response
|
||||
openErr error
|
||||
changeErr error
|
||||
completionErr error
|
||||
}
|
||||
|
||||
type openCall struct {
|
||||
uri string
|
||||
text string
|
||||
version int
|
||||
}
|
||||
|
||||
type changeCall struct {
|
||||
uri string
|
||||
text string
|
||||
version int
|
||||
}
|
||||
|
||||
type completionCall struct {
|
||||
uri string
|
||||
line int
|
||||
character int
|
||||
}
|
||||
|
||||
func (f *fakeLSPClient) DidOpen(_ context.Context, uri, text string, version int) error {
|
||||
f.openCalls = append(f.openCalls, openCall{uri: uri, text: text, version: version})
|
||||
return f.openErr
|
||||
}
|
||||
|
||||
func (f *fakeLSPClient) DidChange(_ context.Context, uri, text string, version int) error {
|
||||
f.changeCalls = append(f.changeCalls, changeCall{uri: uri, text: text, version: version})
|
||||
return f.changeErr
|
||||
}
|
||||
|
||||
func (f *fakeLSPClient) Completion(_ context.Context, uri string, line, character int) (Response, error) {
|
||||
f.completionCalls = append(f.completionCalls, completionCall{uri: uri, line: line, character: character})
|
||||
if f.completionErr != nil {
|
||||
return Response{}, f.completionErr
|
||||
}
|
||||
return f.completionResp, nil
|
||||
}
|
||||
|
||||
func TestServiceCompleteFirstRequestSendsDidOpen(t *testing.T) {
|
||||
fake := &fakeLSPClient{
|
||||
completionResp: Response{Items: []Item{{Label: "Println"}}, IsIncomplete: true},
|
||||
}
|
||||
svc := NewService(fake)
|
||||
|
||||
resp, err := svc.Complete(context.Background(), Request{
|
||||
URI: "file:///main.go",
|
||||
Text: "package main\nfunc main() { fmt.Pr }",
|
||||
Line: 1,
|
||||
Character: 23,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Complete() error = %v", err)
|
||||
}
|
||||
if len(fake.openCalls) != 1 {
|
||||
t.Fatalf("expected 1 didOpen call, got %d", len(fake.openCalls))
|
||||
}
|
||||
if fake.openCalls[0].version != 1 {
|
||||
t.Fatalf("expected didOpen version=1, got %d", fake.openCalls[0].version)
|
||||
}
|
||||
if len(fake.changeCalls) != 0 {
|
||||
t.Fatalf("expected 0 didChange calls, got %d", len(fake.changeCalls))
|
||||
}
|
||||
if len(fake.completionCalls) != 1 {
|
||||
t.Fatalf("expected 1 completion call, got %d", len(fake.completionCalls))
|
||||
}
|
||||
if !resp.IsIncomplete || len(resp.Items) != 1 || resp.Items[0].Label != "Println" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCompleteSecondRequestUsesDidChange(t *testing.T) {
|
||||
fake := &fakeLSPClient{}
|
||||
svc := NewService(fake)
|
||||
|
||||
_, err := svc.Complete(context.Background(), Request{
|
||||
URI: "file:///main.go",
|
||||
Text: "package main\nfunc main() {}",
|
||||
Line: 1,
|
||||
Character: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Complete() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Complete(context.Background(), Request{
|
||||
URI: "file:///main.go",
|
||||
Text: "package main\nfunc main() { fmt.Pr }",
|
||||
Line: 1,
|
||||
Character: 23,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Complete() error = %v", err)
|
||||
}
|
||||
|
||||
if len(fake.openCalls) != 1 {
|
||||
t.Fatalf("expected 1 didOpen call, got %d", len(fake.openCalls))
|
||||
}
|
||||
if len(fake.changeCalls) != 1 {
|
||||
t.Fatalf("expected 1 didChange call, got %d", len(fake.changeCalls))
|
||||
}
|
||||
if fake.changeCalls[0].version != 2 {
|
||||
t.Fatalf("expected didChange version=2, got %d", fake.changeCalls[0].version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCompleteValidatesRequest(t *testing.T) {
|
||||
svc := NewService(&fakeLSPClient{})
|
||||
|
||||
_, err := svc.Complete(context.Background(), Request{URI: "", Text: "", Line: -1, Character: 0})
|
||||
if !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCompleteReturnsClientError(t *testing.T) {
|
||||
fake := &fakeLSPClient{openErr: errors.New("open failed")}
|
||||
svc := NewService(fake)
|
||||
|
||||
_, err := svc.Complete(context.Background(), Request{
|
||||
URI: "file:///main.go",
|
||||
Text: "package main",
|
||||
Line: 0,
|
||||
Character: 0,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
552
backend/internal/lsp/client.go
Normal file
552
backend/internal/lsp/client.go
Normal file
@@ -0,0 +1,552 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"monica-go-completion-backend/internal/completion"
|
||||
)
|
||||
|
||||
var errClientClosed = errors.New("lsp client closed")
|
||||
|
||||
type Client struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
|
||||
writeMu sync.Mutex
|
||||
|
||||
nextID atomic.Int64
|
||||
|
||||
workspaceDir string
|
||||
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]chan rpcResponse
|
||||
|
||||
exitCh chan error
|
||||
closeOnce sync.Once
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
result json.RawMessage
|
||||
err error
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type incomingEnvelope struct {
|
||||
ID *json.RawMessage `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type lspCompletionItem struct {
|
||||
Label string `json:"label"`
|
||||
Kind int `json:"kind,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
InsertText string `json:"insertText,omitempty"`
|
||||
SortText string `json:"sortText,omitempty"`
|
||||
FilterText string `json:"filterText,omitempty"`
|
||||
Documentation json.RawMessage `json:"documentation,omitempty"`
|
||||
}
|
||||
|
||||
type lspCompletionList struct {
|
||||
IsIncomplete bool `json:"isIncomplete"`
|
||||
Items []lspCompletionItem `json:"items"`
|
||||
}
|
||||
|
||||
func NewClient(parent context.Context, goplsPath, rootPath string) (*Client, error) {
|
||||
if goplsPath == "" {
|
||||
goplsPath = "gopls"
|
||||
}
|
||||
if rootPath == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
rootPath = cwd
|
||||
}
|
||||
|
||||
cmd := exec.Command(goplsPath)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start gopls: %w", err)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
workspaceDir: rootPath,
|
||||
pending: make(map[string]chan rpcResponse),
|
||||
exitCh: make(chan error, 1),
|
||||
}
|
||||
|
||||
go func() {
|
||||
client.exitCh <- cmd.Wait()
|
||||
}()
|
||||
go client.readLoop(stdout)
|
||||
|
||||
initCtx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.initialize(initCtx, rootPath); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) initialize(ctx context.Context, rootPath string) error {
|
||||
rootURI, err := pathToURI(rootPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build root uri: %w", err)
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"processId": os.Getpid(),
|
||||
"rootUri": rootURI,
|
||||
"capabilities": map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"completion": map[string]any{
|
||||
"completionItem": map[string]any{
|
||||
"snippetSupport": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"workspaceFolders": []map[string]string{
|
||||
{
|
||||
"uri": rootURI,
|
||||
"name": filepath.Base(rootPath),
|
||||
},
|
||||
},
|
||||
"clientInfo": map[string]string{
|
||||
"name": "monica-go-completion-backend",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
}
|
||||
|
||||
var initResult json.RawMessage
|
||||
if err := c.request(ctx, "initialize", params, &initResult); err != nil {
|
||||
return fmt.Errorf("initialize request failed: %w", err)
|
||||
}
|
||||
if err := c.notify("initialized", map[string]any{}); err != nil {
|
||||
return fmt.Errorf("initialized notification failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DidOpen(ctx context.Context, uri, text string, version int) error {
|
||||
normalizedURI, err := c.normalizeURI(uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": normalizedURI,
|
||||
"languageId": "go",
|
||||
"version": version,
|
||||
"text": text,
|
||||
},
|
||||
}
|
||||
return c.notifyWithContext(ctx, "textDocument/didOpen", params)
|
||||
}
|
||||
|
||||
func (c *Client) DidChange(ctx context.Context, uri, text string, version int) error {
|
||||
normalizedURI, err := c.normalizeURI(uri)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": normalizedURI,
|
||||
"version": version,
|
||||
},
|
||||
"contentChanges": []map[string]string{
|
||||
{
|
||||
"text": text,
|
||||
},
|
||||
},
|
||||
}
|
||||
return c.notifyWithContext(ctx, "textDocument/didChange", params)
|
||||
}
|
||||
|
||||
func (c *Client) Completion(ctx context.Context, uri string, line, character int) (completion.Response, error) {
|
||||
normalizedURI, err := c.normalizeURI(uri)
|
||||
if err != nil {
|
||||
return completion.Response{}, err
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"textDocument": map[string]string{
|
||||
"uri": normalizedURI,
|
||||
},
|
||||
"position": map[string]int{
|
||||
"line": line,
|
||||
"character": character,
|
||||
},
|
||||
}
|
||||
|
||||
var raw json.RawMessage
|
||||
if err := c.request(ctx, "textDocument/completion", params, &raw); err != nil {
|
||||
return completion.Response{}, err
|
||||
}
|
||||
|
||||
body := bytes.TrimSpace(raw)
|
||||
if len(body) == 0 || bytes.Equal(body, []byte("null")) {
|
||||
return completion.Response{}, nil
|
||||
}
|
||||
|
||||
if body[0] == '[' {
|
||||
var items []lspCompletionItem
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return completion.Response{}, fmt.Errorf("decode completion items: %w", err)
|
||||
}
|
||||
return completion.Response{Items: mapCompletionItems(items)}, nil
|
||||
}
|
||||
|
||||
var list lspCompletionList
|
||||
if err := json.Unmarshal(body, &list); err != nil {
|
||||
return completion.Response{}, fmt.Errorf("decode completion list: %w", err)
|
||||
}
|
||||
return completion.Response{
|
||||
Items: mapCompletionItems(list.Items),
|
||||
IsIncomplete: list.IsIncomplete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var windowsDrivePattern = regexp.MustCompile(`^[A-Za-z]:`)
|
||||
|
||||
func (c *Client) normalizeURI(rawURI string) (string, error) {
|
||||
if rawURI == "" {
|
||||
return "", errors.New("empty uri")
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid uri: %w", err)
|
||||
}
|
||||
if u.Scheme != "file" {
|
||||
return rawURI, nil
|
||||
}
|
||||
|
||||
path := u.Path
|
||||
if path == "" {
|
||||
return rawURI, nil
|
||||
}
|
||||
|
||||
var localPath string
|
||||
trimmed := strings.TrimPrefix(path, "/")
|
||||
if windowsDrivePattern.MatchString(trimmed) {
|
||||
localPath = filepath.FromSlash(trimmed)
|
||||
} else {
|
||||
rel := strings.TrimLeft(trimmed, "/\\")
|
||||
localPath = filepath.Join(c.workspaceDir, filepath.FromSlash(rel))
|
||||
}
|
||||
|
||||
return pathToURI(localPath)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_ = c.request(shutdownCtx, "shutdown", map[string]any{}, nil)
|
||||
_ = c.notify("exit", map[string]any{})
|
||||
_ = c.stdin.Close()
|
||||
|
||||
select {
|
||||
case <-c.exitCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
if c.cmd.Process != nil {
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
<-c.exitCh
|
||||
}
|
||||
|
||||
c.closed.Store(true)
|
||||
c.failPending(errClientClosed)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method string, params any, out any) error {
|
||||
if c.closed.Load() {
|
||||
return errClientClosed
|
||||
}
|
||||
|
||||
id := c.nextID.Add(1)
|
||||
key := strconv.FormatInt(id, 10)
|
||||
wait := make(chan rpcResponse, 1)
|
||||
|
||||
c.pendingMu.Lock()
|
||||
c.pending[key] = wait
|
||||
c.pendingMu.Unlock()
|
||||
|
||||
msg := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
if err := c.writeMessage(msg); err != nil {
|
||||
c.removePending(key)
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.removePending(key)
|
||||
return ctx.Err()
|
||||
case resp := <-wait:
|
||||
if resp.err != nil {
|
||||
return resp.err
|
||||
}
|
||||
if out != nil && len(resp.result) > 0 && !bytes.Equal(bytes.TrimSpace(resp.result), []byte("null")) {
|
||||
if err := json.Unmarshal(resp.result, out); err != nil {
|
||||
return fmt.Errorf("decode response for %s: %w", method, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) notify(method string, params any) error {
|
||||
return c.notifyWithContext(context.Background(), method, params)
|
||||
}
|
||||
|
||||
func (c *Client) notifyWithContext(ctx context.Context, method string, params any) error {
|
||||
if c.closed.Load() {
|
||||
return errClientClosed
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
msg := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
return c.writeMessage(msg)
|
||||
}
|
||||
|
||||
func (c *Client) writeMessage(msg any) error {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal json-rpc message: %w", err)
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload))
|
||||
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
if _, err := io.WriteString(c.stdin, header); err != nil {
|
||||
return fmt.Errorf("write lsp header: %w", err)
|
||||
}
|
||||
if _, err := c.stdin.Write(payload); err != nil {
|
||||
return fmt.Errorf("write lsp payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(stdout io.Reader) {
|
||||
reader := bufio.NewReader(stdout)
|
||||
for {
|
||||
body, err := readMessage(reader)
|
||||
if err != nil {
|
||||
c.failPending(fmt.Errorf("read lsp message: %w", err))
|
||||
return
|
||||
}
|
||||
c.handleIncoming(body)
|
||||
}
|
||||
}
|
||||
|
||||
func readMessage(reader *bufio.Reader) ([]byte, error) {
|
||||
contentLength := 0
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(parts[0]), "Content-Length") {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid content-length %q: %w", parts[1], err)
|
||||
}
|
||||
contentLength = n
|
||||
}
|
||||
}
|
||||
|
||||
if contentLength <= 0 {
|
||||
return nil, errors.New("missing content-length")
|
||||
}
|
||||
|
||||
body := make([]byte, contentLength)
|
||||
if _, err := io.ReadFull(reader, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *Client) handleIncoming(body []byte) {
|
||||
var envelope incomingEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if envelope.ID == nil {
|
||||
return
|
||||
}
|
||||
|
||||
key := normalizeID(*envelope.ID)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
c.pendingMu.Lock()
|
||||
wait := c.pending[key]
|
||||
delete(c.pending, key)
|
||||
c.pendingMu.Unlock()
|
||||
|
||||
if wait == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if envelope.Error != nil {
|
||||
wait <- rpcResponse{
|
||||
err: fmt.Errorf("lsp error code=%d message=%s", envelope.Error.Code, envelope.Error.Message),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
wait <- rpcResponse{result: envelope.Result}
|
||||
}
|
||||
|
||||
func normalizeID(raw json.RawMessage) string {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if raw[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (c *Client) removePending(key string) {
|
||||
c.pendingMu.Lock()
|
||||
delete(c.pending, key)
|
||||
c.pendingMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) failPending(err error) {
|
||||
c.pendingMu.Lock()
|
||||
defer c.pendingMu.Unlock()
|
||||
|
||||
for key, wait := range c.pending {
|
||||
delete(c.pending, key)
|
||||
wait <- rpcResponse{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func mapCompletionItems(items []lspCompletionItem) []completion.Item {
|
||||
out := make([]completion.Item, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, completion.Item{
|
||||
Label: it.Label,
|
||||
Kind: it.Kind,
|
||||
Detail: it.Detail,
|
||||
Documentation: decodeDocumentation(it.Documentation),
|
||||
InsertText: it.InsertText,
|
||||
SortText: it.SortText,
|
||||
FilterText: it.FilterText,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeDocumentation(raw json.RawMessage) string {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if raw[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var markup struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &markup); err == nil && markup.Value != "" {
|
||||
return markup.Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func pathToURI(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
slashed := filepath.ToSlash(abs)
|
||||
if !strings.HasPrefix(slashed, "/") {
|
||||
slashed = "/" + slashed
|
||||
}
|
||||
u := url.URL{Scheme: "file", Path: slashed}
|
||||
return u.String(), nil
|
||||
}
|
||||
49
backend/internal/lsp/client_test.go
Normal file
49
backend/internal/lsp/client_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeURIRebasesRelativeFileURI(t *testing.T) {
|
||||
workspace, err := filepath.Abs(filepath.Join("testdata", "ws"))
|
||||
if err != nil {
|
||||
t.Fatalf("filepath.Abs() error = %v", err)
|
||||
}
|
||||
|
||||
client := &Client{workspaceDir: workspace}
|
||||
got, err := client.normalizeURI("file:///main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeURI() error = %v", err)
|
||||
}
|
||||
|
||||
want, err := pathToURI(filepath.Join(workspace, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("pathToURI() error = %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("normalizeURI() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeURIKeepsAbsoluteFileURI(t *testing.T) {
|
||||
workspace, err := filepath.Abs(filepath.Join("testdata", "ws"))
|
||||
if err != nil {
|
||||
t.Fatalf("filepath.Abs() error = %v", err)
|
||||
}
|
||||
|
||||
client := &Client{workspaceDir: workspace}
|
||||
absPath := filepath.Join(workspace, "demo.go")
|
||||
uri, err := pathToURI(absPath)
|
||||
if err != nil {
|
||||
t.Fatalf("pathToURI() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := client.normalizeURI(uri)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeURI() error = %v", err)
|
||||
}
|
||||
if got != uri {
|
||||
t.Fatalf("normalizeURI() = %q, want %q", got, uri)
|
||||
}
|
||||
}
|
||||
7
env.d.ts
vendored
Normal file
7
env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Monica Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1511
package-lock.json
generated
Normal file
1511
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "monica-editor",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"monaco-editor": "^0.52.2",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
25
tsconfig.app.json
Normal file
25
tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
|
||||
}
|
||||
1
tsconfig.app.tsbuildinfo
Normal file
1
tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/main.ts","./src/api/completion.ts","./src/types/completion.ts","./src/app.vue","./src/components/monacoeditor.vue","./src/components/themetoggle.vue","./env.d.ts"],"version":"5.7.3"}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
tsconfig.node.tsbuildinfo
Normal file
1
tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.7.3"}
|
||||
19
vite.config.ts
Normal file
19
vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:8080',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user