feat: improve editors (#226)
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/common/scripts"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
lspinstaller "github.com/kandev/kandev/internal/lsp/installer"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
gateways "github.com/kandev/kandev/internal/gateway/websocket"
|
||||
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
|
||||
@@ -53,16 +54,16 @@ func provideGateway(
|
||||
notificationRepo notificationstore.Repository,
|
||||
taskRepo repository.Repository,
|
||||
) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, error) {
|
||||
gateway, cleanup, err := gateways.Provide(log)
|
||||
gateway, err := gateways.Provide(log)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
_ = cleanup
|
||||
|
||||
// Enable dedicated terminal WebSocket for passthrough mode
|
||||
scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc}
|
||||
if lifecycleMgr != nil {
|
||||
gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc)
|
||||
gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(log))
|
||||
}
|
||||
|
||||
orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log)
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/lsp/installer"
|
||||
"github.com/kandev/kandev/internal/user/models"
|
||||
)
|
||||
|
||||
// Custom WebSocket close codes for LSP connections.
|
||||
const (
|
||||
lspCloseBinaryNotFound = 4001
|
||||
lspCloseSessionNotFound = 4002
|
||||
lspCloseInstallFailed = 4003
|
||||
)
|
||||
|
||||
// LSPUserService provides user settings for the LSP handler.
|
||||
type LSPUserService interface {
|
||||
GetUserSettings(ctx context.Context) (*models.UserSettings, error)
|
||||
}
|
||||
|
||||
// LSPHandler handles WebSocket connections for LSP (Language Server Protocol) proxying.
|
||||
// Each WebSocket connection gets its own dedicated LSP server process (1:1 mapping).
|
||||
// This avoids LSP protocol issues with shared servers (duplicate initialize,
|
||||
// request ID collisions between clients).
|
||||
type LSPHandler struct {
|
||||
lifecycleMgr *lifecycle.Manager
|
||||
userService LSPUserService
|
||||
installer *installer.Registry
|
||||
logger *logger.Logger
|
||||
installMu sync.Mutex
|
||||
installing map[string]chan struct{}
|
||||
}
|
||||
|
||||
// lspServerProcess represents a single LSP server process for one WebSocket client.
|
||||
type lspServerProcess struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
var lspUpgrader = gorillaws.Upgrader{
|
||||
ReadBufferSize: 8192,
|
||||
WriteBufferSize: 8192,
|
||||
CheckOrigin: checkWebSocketOrigin,
|
||||
}
|
||||
|
||||
// NewLSPHandler creates a new LSPHandler.
|
||||
func NewLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, installerRegistry *installer.Registry, log *logger.Logger) *LSPHandler {
|
||||
return &LSPHandler{
|
||||
lifecycleMgr: lifecycleMgr,
|
||||
userService: userService,
|
||||
installer: installerRegistry,
|
||||
logger: log.WithFields(zap.String("component", "lsp_handler")),
|
||||
installing: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLSPConnection handles WebSocket connections at /lsp/:sessionId?language=...
|
||||
func (h *LSPHandler) HandleLSPConnection(c *gin.Context) {
|
||||
sessionID := c.Param("sessionId")
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "sessionId is required"})
|
||||
return
|
||||
}
|
||||
|
||||
language := c.Query("language")
|
||||
if language == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "language query parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate language against the registry
|
||||
if !installer.IsSupported(language) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unsupported language: %s", language)})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("LSP WebSocket connection request",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language))
|
||||
|
||||
// Get workspace path from execution
|
||||
execution, exists := h.lifecycleMgr.GetExecutionBySessionID(sessionID)
|
||||
if !exists {
|
||||
h.logger.Warn("LSP: session not found in lifecycle manager",
|
||||
zap.String("session_id", sessionID))
|
||||
conn, err := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(err))
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(lspCloseSessionNotFound, "session not found"))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
workspacePath := execution.WorkspacePath
|
||||
h.logger.Debug("LSP: found execution",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("workspace_path", workspacePath))
|
||||
|
||||
// Check if binary is available
|
||||
binaryPath, err := h.installer.BinaryPath(language)
|
||||
if err != nil {
|
||||
h.logger.Info("LSP: binary not found",
|
||||
zap.String("language", language),
|
||||
zap.Error(err))
|
||||
// Binary not found — check auto-install setting
|
||||
settings, settingsErr := h.userService.GetUserSettings(c.Request.Context())
|
||||
autoInstall := false
|
||||
if settingsErr == nil && settings != nil {
|
||||
for _, lang := range settings.LspAutoInstallLanguages {
|
||||
if lang == language {
|
||||
autoInstall = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
h.logger.Debug("LSP: auto-install check",
|
||||
zap.String("language", language),
|
||||
zap.Bool("auto_install", autoInstall),
|
||||
zap.Error(settingsErr))
|
||||
|
||||
if !autoInstall {
|
||||
h.logger.Info("LSP: closing with binary not found (auto-install off)",
|
||||
zap.String("language", language),
|
||||
zap.String("error", err.Error()))
|
||||
conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if upgradeErr != nil {
|
||||
h.logger.Error("LSP: failed to upgrade WebSocket for close", zap.Error(upgradeErr))
|
||||
return
|
||||
}
|
||||
_ = conn.WriteMessage(gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(lspCloseBinaryNotFound, err.Error()))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-install: upgrade WS, send status messages, install
|
||||
conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if upgradeErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := writeJSONMessage(conn, map[string]string{"status": "installing", "language": language}); err != nil {
|
||||
h.logger.Warn("failed to send status message", zap.Error(err))
|
||||
}
|
||||
|
||||
// Concurrency protection: wait if another install for the same language is in progress
|
||||
binaryPath, err = h.awaitOrInstall(c.Request.Context(), language)
|
||||
if err != nil {
|
||||
h.logger.Error("LSP auto-install failed",
|
||||
zap.String("language", language),
|
||||
zap.Error(err))
|
||||
if err := writeJSONMessage(conn, map[string]string{"status": "install_failed", "error": err.Error()}); err != nil {
|
||||
h.logger.Warn("failed to send status message", zap.Error(err))
|
||||
}
|
||||
_ = conn.WriteMessage(gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(lspCloseInstallFailed, "install failed"))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
if err := writeJSONMessage(conn, map[string]string{"status": "installed"}); err != nil {
|
||||
h.logger.Warn("failed to send status message", zap.Error(err))
|
||||
}
|
||||
|
||||
// Continue with the already-upgraded connection
|
||||
h.handleLSPBridge(conn, sessionID, language, binaryPath, workspacePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Binary found — upgrade and bridge
|
||||
h.logger.Info("LSP: binary found, upgrading WebSocket",
|
||||
zap.String("language", language),
|
||||
zap.String("binary_path", binaryPath))
|
||||
conn, upgradeErr := lspUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if upgradeErr != nil {
|
||||
h.logger.Error("LSP: failed to upgrade to WebSocket",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(upgradeErr))
|
||||
return
|
||||
}
|
||||
|
||||
h.handleLSPBridge(conn, sessionID, language, binaryPath, workspacePath)
|
||||
}
|
||||
|
||||
// awaitOrInstall either waits for an in-progress installation to finish or
|
||||
// performs the installation itself, broadcasting completion to other waiters.
|
||||
func (h *LSPHandler) awaitOrInstall(ctx context.Context, language string) (string, error) {
|
||||
h.installMu.Lock()
|
||||
if ch, ok := h.installing[language]; ok {
|
||||
// Another goroutine is already installing — wait for it
|
||||
h.installMu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
// Install completed; look up the binary path
|
||||
return h.installer.BinaryPath(language)
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
// Claim the install slot
|
||||
ch := make(chan struct{})
|
||||
h.installing[language] = ch
|
||||
h.installMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
h.installMu.Lock()
|
||||
delete(h.installing, language)
|
||||
close(ch)
|
||||
h.installMu.Unlock()
|
||||
}()
|
||||
|
||||
strategy, err := h.installer.StrategyFor(language)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := strategy.Install(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.BinaryPath, nil
|
||||
}
|
||||
|
||||
func (h *LSPHandler) handleLSPBridge(conn *gorillaws.Conn, sessionID, language, binaryPath, workspacePath string) {
|
||||
server, err := h.startServer(language, binaryPath, workspacePath)
|
||||
if err != nil {
|
||||
h.logger.Error("LSP: failed to start language server",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language),
|
||||
zap.Error(err))
|
||||
_ = conn.WriteMessage(gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(gorillaws.CloseInternalServerErr, err.Error()))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("LSP: server started, sending ready signal",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language),
|
||||
zap.Int("pid", server.cmd.Process.Pid))
|
||||
|
||||
// Signal the frontend that the language server is ready.
|
||||
// Include workspace path so the frontend can set rootUri in the LSP initialize request.
|
||||
if err := writeJSONMessage(conn, map[string]string{"status": "ready", "workspacePath": workspacePath}); err != nil {
|
||||
h.logger.Error("LSP: failed to send ready message", zap.Error(err))
|
||||
h.stopServer(server)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
h.runLSPBridge(conn, sessionID, language, server)
|
||||
h.stopServer(server)
|
||||
|
||||
h.logger.Info("LSP: connection closed",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language))
|
||||
}
|
||||
|
||||
func (h *LSPHandler) startServer(language, binaryPath, workspacePath string) (*lspServerProcess, error) {
|
||||
binary, args := installer.LspCommand(language)
|
||||
if binaryPath != "" {
|
||||
binary = binaryPath
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
cmd.Dir = workspacePath
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to start %s: %w", binary, err)
|
||||
}
|
||||
|
||||
return &lspServerProcess{cmd: cmd, stdin: stdin, stdout: stdout, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func (h *LSPHandler) stopServer(server *lspServerProcess) {
|
||||
server.cancel()
|
||||
_ = server.stdin.Close()
|
||||
_ = server.cmd.Wait()
|
||||
}
|
||||
|
||||
// runLSPBridge runs bidirectional proxying between a WebSocket and an LSP server.
|
||||
// Blocks until the WebSocket closes or the server exits.
|
||||
func (h *LSPHandler) runLSPBridge(conn *gorillaws.Conn, sessionID, language string, server *lspServerProcess) {
|
||||
done := make(chan struct{})
|
||||
|
||||
// stdout → WebSocket
|
||||
go func() {
|
||||
defer close(done)
|
||||
reader := bufio.NewReader(server.stdout)
|
||||
for {
|
||||
msg, err := readLSPMessage(reader)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
h.logger.Debug("LSP stdout read error",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language),
|
||||
zap.Error(err))
|
||||
}
|
||||
// Server exited — close WebSocket so WS→stdin loop exits
|
||||
_ = conn.WriteMessage(gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(gorillaws.CloseNormalClosure, "language server exited"))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if wErr := conn.WriteMessage(gorillaws.TextMessage, msg); wErr != nil {
|
||||
h.logger.Debug("LSP WebSocket write error",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language),
|
||||
zap.Error(wErr))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// WebSocket → stdin
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if !gorillaws.IsCloseError(err, gorillaws.CloseNormalClosure, gorillaws.CloseGoingAway) {
|
||||
h.logger.Debug("LSP WebSocket read error",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language),
|
||||
zap.Error(err))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Wrap with Content-Length header for the language server
|
||||
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg))
|
||||
if _, err := server.stdin.Write([]byte(header)); err != nil {
|
||||
h.logger.Debug("LSP stdin write error",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language),
|
||||
zap.Error(err))
|
||||
break
|
||||
}
|
||||
if _, err := server.stdin.Write(msg); err != nil {
|
||||
h.logger.Debug("LSP stdin write error",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("language", language),
|
||||
zap.Error(err))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Kill server so stdout goroutine exits, then wait for it
|
||||
server.cancel()
|
||||
_ = server.stdin.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
// readLSPMessage reads a single LSP message from a reader, parsing Content-Length headers.
|
||||
func readLSPMessage(reader *bufio.Reader) ([]byte, error) {
|
||||
contentLength := -1
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
// End of headers
|
||||
break
|
||||
}
|
||||
if after, found := strings.CutPrefix(line, "Content-Length: "); found {
|
||||
n, err := strconv.Atoi(after)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Content-Length: %s", after)
|
||||
}
|
||||
contentLength = n
|
||||
}
|
||||
}
|
||||
|
||||
if contentLength < 0 {
|
||||
return nil, fmt.Errorf("missing Content-Length header")
|
||||
}
|
||||
|
||||
body := make([]byte, contentLength)
|
||||
if _, err := io.ReadFull(reader, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func writeJSONMessage(conn *gorillaws.Conn, data any) error {
|
||||
msg, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.WriteMessage(gorillaws.TextMessage, msg)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/lsp/installer"
|
||||
)
|
||||
|
||||
func TestReadLSPMessage_Valid(t *testing.T) {
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
|
||||
raw := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
|
||||
reader := bufio.NewReader(strings.NewReader(raw))
|
||||
|
||||
msg, err := readLSPMessage(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("readLSPMessage() error = %v", err)
|
||||
}
|
||||
if string(msg) != body {
|
||||
t.Errorf("readLSPMessage() = %q, want %q", string(msg), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLSPMessage_WithExtraHeaders(t *testing.T) {
|
||||
body := `{"jsonrpc":"2.0","id":2}`
|
||||
raw := fmt.Sprintf("Content-Length: %d\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n%s", len(body), body)
|
||||
reader := bufio.NewReader(strings.NewReader(raw))
|
||||
|
||||
msg, err := readLSPMessage(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("readLSPMessage() error = %v", err)
|
||||
}
|
||||
if string(msg) != body {
|
||||
t.Errorf("readLSPMessage() = %q, want %q", string(msg), body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLSPMessage_MissingContentLength(t *testing.T) {
|
||||
raw := "Content-Type: application/json\r\n\r\n{}"
|
||||
reader := bufio.NewReader(strings.NewReader(raw))
|
||||
|
||||
_, err := readLSPMessage(reader)
|
||||
if err == nil {
|
||||
t.Fatal("readLSPMessage() should return error for missing Content-Length")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing Content-Length") {
|
||||
t.Errorf("readLSPMessage() error = %q, want 'missing Content-Length'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLSPMessage_InvalidContentLength(t *testing.T) {
|
||||
raw := "Content-Length: abc\r\n\r\n{}"
|
||||
reader := bufio.NewReader(strings.NewReader(raw))
|
||||
|
||||
_, err := readLSPMessage(reader)
|
||||
if err == nil {
|
||||
t.Fatal("readLSPMessage() should return error for invalid Content-Length")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid Content-Length") {
|
||||
t.Errorf("readLSPMessage() error = %q, want 'invalid Content-Length'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLSPMessage_EOF(t *testing.T) {
|
||||
reader := bufio.NewReader(bytes.NewReader(nil))
|
||||
|
||||
_, err := readLSPMessage(reader)
|
||||
if err == nil {
|
||||
t.Fatal("readLSPMessage() should return error on EOF")
|
||||
}
|
||||
if err != io.EOF {
|
||||
t.Errorf("readLSPMessage() error = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLSPMessage_MultipleMessages(t *testing.T) {
|
||||
body1 := `{"jsonrpc":"2.0","id":1}`
|
||||
body2 := `{"jsonrpc":"2.0","id":2}`
|
||||
raw := fmt.Sprintf("Content-Length: %d\r\n\r\n%sContent-Length: %d\r\n\r\n%s",
|
||||
len(body1), body1, len(body2), body2)
|
||||
reader := bufio.NewReader(strings.NewReader(raw))
|
||||
|
||||
msg1, err := readLSPMessage(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("first readLSPMessage() error = %v", err)
|
||||
}
|
||||
if string(msg1) != body1 {
|
||||
t.Errorf("first readLSPMessage() = %q, want %q", string(msg1), body1)
|
||||
}
|
||||
|
||||
msg2, err := readLSPMessage(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("second readLSPMessage() error = %v", err)
|
||||
}
|
||||
if string(msg2) != body2 {
|
||||
t.Errorf("second readLSPMessage() = %q, want %q", string(msg2), body2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLspCommand_ViaRegistry(t *testing.T) {
|
||||
tests := []struct {
|
||||
language string
|
||||
wantBinary string
|
||||
wantArgs []string
|
||||
}{
|
||||
{"typescript", "typescript-language-server", []string{"--stdio"}},
|
||||
{"go", "gopls", []string{"serve"}},
|
||||
{"rust", "rust-analyzer", nil},
|
||||
{"python", "pyright-langserver", []string{"--stdio"}},
|
||||
{"unknown", "", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
binary, args := installer.LspCommand(tc.language)
|
||||
if binary != tc.wantBinary {
|
||||
t.Errorf("LspCommand(%q) binary = %q, want %q", tc.language, binary, tc.wantBinary)
|
||||
}
|
||||
if len(args) != len(tc.wantArgs) {
|
||||
t.Errorf("LspCommand(%q) args = %v, want %v", tc.language, args, tc.wantArgs)
|
||||
continue
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] != tc.wantArgs[i] {
|
||||
t.Errorf("LspCommand(%q) args[%d] = %q, want %q", tc.language, i, args[i], tc.wantArgs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidLSPLanguage_ViaRegistry(t *testing.T) {
|
||||
tests := []struct {
|
||||
language string
|
||||
want bool
|
||||
}{
|
||||
{"typescript", true},
|
||||
{"go", true},
|
||||
{"rust", true},
|
||||
{"python", true},
|
||||
{"java", false},
|
||||
{"", false},
|
||||
{"ruby", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := installer.IsSupported(tc.language); got != tc.want {
|
||||
t.Errorf("IsSupported(%q) = %v, want %v", tc.language, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseCodeConstants(t *testing.T) {
|
||||
// Verify close codes are in the expected range (4000-4999 for application-specific)
|
||||
codes := []struct {
|
||||
name string
|
||||
code int
|
||||
}{
|
||||
{"lspCloseBinaryNotFound", lspCloseBinaryNotFound},
|
||||
{"lspCloseSessionNotFound", lspCloseSessionNotFound},
|
||||
{"lspCloseInstallFailed", lspCloseInstallFailed},
|
||||
}
|
||||
for _, tc := range codes {
|
||||
if tc.code < 4000 || tc.code > 4999 {
|
||||
t.Errorf("%s = %d, want value in range 4000-4999", tc.name, tc.code)
|
||||
}
|
||||
}
|
||||
// Verify they're distinct
|
||||
seen := make(map[int]string)
|
||||
for _, tc := range codes {
|
||||
if prev, ok := seen[tc.code]; ok {
|
||||
t.Errorf("%s and %s have the same code %d", prev, tc.name, tc.code)
|
||||
}
|
||||
seen[tc.code] = tc.name
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package websocket
|
||||
import "github.com/kandev/kandev/internal/common/logger"
|
||||
|
||||
// Provide creates the unified WebSocket gateway.
|
||||
func Provide(log *logger.Logger) (*Gateway, func() error, error) {
|
||||
func Provide(log *logger.Logger) (*Gateway, error) {
|
||||
gateway := NewGateway(log)
|
||||
return gateway, func() error { return nil }, nil
|
||||
return gateway, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/common/scripts"
|
||||
"github.com/kandev/kandev/internal/lsp/installer"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
@@ -15,6 +16,7 @@ type Gateway struct {
|
||||
Dispatcher *ws.Dispatcher
|
||||
Handler *Handler
|
||||
TerminalHandler *TerminalHandler
|
||||
LSPHandler *LSPHandler
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
@@ -41,6 +43,11 @@ func (g *Gateway) SetLifecycleManager(lifecycleMgr *lifecycle.Manager, userServi
|
||||
g.TerminalHandler = NewTerminalHandler(lifecycleMgr, userService, scriptService, g.logger)
|
||||
}
|
||||
|
||||
// SetLSPHandler enables the LSP WebSocket handler.
|
||||
func (g *Gateway) SetLSPHandler(lifecycleMgr *lifecycle.Manager, userService LSPUserService, installerRegistry *installer.Registry) {
|
||||
g.LSPHandler = NewLSPHandler(lifecycleMgr, userService, installerRegistry, g.logger)
|
||||
}
|
||||
|
||||
// SetupRoutes adds the WebSocket routes to the Gin engine
|
||||
func (g *Gateway) SetupRoutes(router *gin.Engine) {
|
||||
router.GET("/ws", g.Handler.HandleConnection)
|
||||
@@ -49,5 +56,10 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) {
|
||||
if g.TerminalHandler != nil {
|
||||
router.GET("/xterm.js/:sessionId", g.TerminalHandler.HandleTerminalWS)
|
||||
}
|
||||
|
||||
// Add LSP routes if LSP handler is configured
|
||||
if g.LSPHandler != nil {
|
||||
router.GET("/lsp/:sessionId", g.LSPHandler.HandleLSPConnection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GithubReleaseConfig configures a GitHub release download.
|
||||
type GithubReleaseConfig struct {
|
||||
Owner string // e.g. "rust-lang"
|
||||
Repo string // e.g. "rust-analyzer"
|
||||
AssetPattern string // e.g. "rust-analyzer-{target}.gz"
|
||||
Targets map[string]string // "darwin/arm64" → "aarch64-apple-darwin"
|
||||
}
|
||||
|
||||
// GithubReleaseStrategy downloads prebuilt binaries from GitHub releases.
|
||||
// Used for Rust (rust-analyzer).
|
||||
type GithubReleaseStrategy struct {
|
||||
binDir string
|
||||
binary string // "rust-analyzer"
|
||||
config GithubReleaseConfig
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewGithubReleaseStrategy creates a new GitHub release download strategy.
|
||||
func NewGithubReleaseStrategy(binDir, binary string, config GithubReleaseConfig, log *logger.Logger) *GithubReleaseStrategy {
|
||||
return &GithubReleaseStrategy{
|
||||
binDir: binDir,
|
||||
binary: binary,
|
||||
config: config,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GithubReleaseStrategy) Name() string {
|
||||
return fmt.Sprintf("github release %s/%s", s.config.Owner, s.config.Repo)
|
||||
}
|
||||
|
||||
func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, error) {
|
||||
// Resolve target from runtime OS/ARCH
|
||||
targetKey := runtime.GOOS + "/" + runtime.GOARCH
|
||||
target, ok := s.config.Targets[targetKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported platform: %s", targetKey)
|
||||
}
|
||||
|
||||
// Build download URL
|
||||
asset := strings.Replace(s.config.AssetPattern, "{target}", target, 1)
|
||||
url := fmt.Sprintf("https://github.com/%s/%s/releases/latest/download/%s",
|
||||
s.config.Owner, s.config.Repo, asset)
|
||||
|
||||
s.logger.Info("downloading from GitHub releases",
|
||||
zap.String("url", url),
|
||||
zap.String("target", target))
|
||||
|
||||
// Download
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Ensure bin directory exists
|
||||
if err := os.MkdirAll(s.binDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", s.binDir, err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(s.binDir, s.binary)
|
||||
|
||||
// Decompress .gz
|
||||
if strings.HasSuffix(asset, ".gz") {
|
||||
gzReader, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer func() { _ = gzReader.Close() }()
|
||||
|
||||
outFile, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = outFile.Close() }()
|
||||
|
||||
if _, err := io.Copy(outFile, gzReader); err != nil {
|
||||
return nil, fmt.Errorf("failed to decompress: %w", err)
|
||||
}
|
||||
} else {
|
||||
outFile, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = outFile.Close() }()
|
||||
|
||||
if _, err := io.Copy(outFile, resp.Body); err != nil {
|
||||
return nil, fmt.Errorf("failed to write binary: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Make executable
|
||||
if err := os.Chmod(outPath, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to chmod: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("GitHub release download completed", zap.String("binary", outPath))
|
||||
return &InstallResult{
|
||||
BinaryPath: outPath,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GoInstallStrategy installs language servers via `go install`.
|
||||
// Used for Go (gopls).
|
||||
type GoInstallStrategy struct {
|
||||
binary string // "gopls"
|
||||
importPath string // "golang.org/x/tools/gopls@latest"
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewGoInstallStrategy creates a new go install strategy.
|
||||
func NewGoInstallStrategy(binary, importPath string, log *logger.Logger) *GoInstallStrategy {
|
||||
return &GoInstallStrategy{
|
||||
binary: binary,
|
||||
importPath: importPath,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GoInstallStrategy) Name() string {
|
||||
return fmt.Sprintf("go install %s", s.importPath)
|
||||
}
|
||||
|
||||
func (s *GoInstallStrategy) Install(ctx context.Context) (*InstallResult, error) {
|
||||
goPath, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go not found: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("installing via go install", zap.String("import_path", s.importPath))
|
||||
|
||||
cmd := exec.CommandContext(ctx, goPath, "install", s.importPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go install failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
// Find the installed binary using the shared Go binary lookup
|
||||
binaryPath, err := findGoBinary(s.binary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("go install completed", zap.String("binary", binaryPath))
|
||||
return &InstallResult{
|
||||
BinaryPath: binaryPath,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package installer
|
||||
|
||||
import "context"
|
||||
|
||||
// InstallResult contains information about an installed language server binary.
|
||||
type InstallResult struct {
|
||||
BinaryPath string // absolute path to installed binary
|
||||
}
|
||||
|
||||
// Strategy is the abstraction for different install methods.
|
||||
type Strategy interface {
|
||||
// Install downloads/installs the language server. Blocks until done.
|
||||
Install(ctx context.Context) (*InstallResult, error)
|
||||
// Name returns a human-readable name for logging.
|
||||
Name() string
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// NpmStrategy installs language servers via npm.
|
||||
// Used for TypeScript (typescript-language-server) and Python (pyright).
|
||||
type NpmStrategy struct {
|
||||
binDir string // ~/.kandev/lsp-servers
|
||||
binary string // e.g. "typescript-language-server"
|
||||
packages []string // e.g. ["typescript-language-server", "typescript"]
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewNpmStrategy creates a new npm install strategy.
|
||||
func NewNpmStrategy(binDir, binary string, packages []string, log *logger.Logger) *NpmStrategy {
|
||||
return &NpmStrategy{
|
||||
binDir: binDir,
|
||||
binary: binary,
|
||||
packages: packages,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NpmStrategy) Name() string {
|
||||
return fmt.Sprintf("npm install %s", strings.Join(s.packages, " "))
|
||||
}
|
||||
|
||||
func (s *NpmStrategy) Install(ctx context.Context) (*InstallResult, error) {
|
||||
npmPath, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("npm not found: %w", err)
|
||||
}
|
||||
|
||||
// Ensure bin directory exists
|
||||
if err := os.MkdirAll(s.binDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", s.binDir, err)
|
||||
}
|
||||
|
||||
args := append([]string{"install", "--prefix", s.binDir}, s.packages...)
|
||||
cmd := exec.CommandContext(ctx, npmPath, args...)
|
||||
cmd.Dir = s.binDir
|
||||
|
||||
s.logger.Info("installing via npm", zap.Strings("packages", s.packages), zap.String("prefix", s.binDir))
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("npm install failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
binaryPath := filepath.Join(s.binDir, "node_modules", ".bin", s.binary)
|
||||
if _, err := os.Stat(binaryPath); err != nil {
|
||||
return nil, fmt.Errorf("binary not found after install: %s", binaryPath)
|
||||
}
|
||||
|
||||
s.logger.Info("npm install completed", zap.String("binary", binaryPath))
|
||||
return &InstallResult{
|
||||
BinaryPath: binaryPath,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DefaultBinDir is where LSP binaries installed by Kandev are placed.
|
||||
const DefaultBinDir = ".kandev/lsp-servers"
|
||||
|
||||
// languageConfig holds the binary name and CLI arguments for a language server.
|
||||
type languageConfig struct {
|
||||
binary string
|
||||
args []string
|
||||
}
|
||||
|
||||
// languages is the single source of truth for supported LSP languages.
|
||||
var languages = map[string]languageConfig{
|
||||
"typescript": {binary: "typescript-language-server", args: []string{"--stdio"}},
|
||||
"go": {binary: "gopls", args: []string{"serve"}},
|
||||
"rust": {binary: "rust-analyzer", args: nil},
|
||||
"python": {binary: "pyright-langserver", args: []string{"--stdio"}},
|
||||
}
|
||||
|
||||
// SupportedLanguages returns the set of supported LSP language identifiers.
|
||||
func SupportedLanguages() map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(languages))
|
||||
for lang := range languages {
|
||||
result[lang] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsSupported returns true if the language has a registered LSP configuration.
|
||||
func IsSupported(language string) bool {
|
||||
_, ok := languages[language]
|
||||
return ok
|
||||
}
|
||||
|
||||
// LspCommand returns the binary name and arguments for a language server.
|
||||
func LspCommand(language string) (binary string, args []string) {
|
||||
cfg, ok := languages[language]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return cfg.binary, cfg.args
|
||||
}
|
||||
|
||||
// binaryName returns the expected binary name for a language.
|
||||
func binaryName(language string) (string, error) {
|
||||
cfg, ok := languages[language]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unsupported language: %s", language)
|
||||
}
|
||||
return cfg.binary, nil
|
||||
}
|
||||
|
||||
// findGoBinary looks for a Go binary in GOBIN, GOPATH/bin, and ~/go/bin.
|
||||
func findGoBinary(binary string) (string, error) {
|
||||
if gobin := os.Getenv("GOBIN"); gobin != "" {
|
||||
p := filepath.Join(gobin, binary)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if gopath := os.Getenv("GOPATH"); gopath != "" {
|
||||
p := filepath.Join(gopath, "bin", binary)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
p := filepath.Join(home, "go", "bin", binary)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s not found in GOBIN/GOPATH/~/go/bin", binary)
|
||||
}
|
||||
|
||||
// Registry maps language IDs to install strategies and resolves binary paths.
|
||||
type Registry struct {
|
||||
binDir string // resolved absolute path
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewRegistry creates a new installer registry.
|
||||
func NewRegistry(log *logger.Logger) *Registry {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = "."
|
||||
}
|
||||
return &Registry{
|
||||
binDir: filepath.Join(home, DefaultBinDir),
|
||||
logger: log.WithFields(zap.String("component", "lsp-installer")),
|
||||
}
|
||||
}
|
||||
|
||||
// StrategyFor returns the install strategy for a language.
|
||||
func (r *Registry) StrategyFor(language string) (Strategy, error) {
|
||||
switch language {
|
||||
case "typescript":
|
||||
return NewNpmStrategy(r.binDir, "typescript-language-server", []string{"typescript-language-server", "typescript"}, r.logger), nil
|
||||
case "go":
|
||||
return NewGoInstallStrategy("gopls", "golang.org/x/tools/gopls@latest", r.logger), nil
|
||||
case "rust":
|
||||
return NewGithubReleaseStrategy(r.binDir, "rust-analyzer", GithubReleaseConfig{
|
||||
Owner: "rust-lang",
|
||||
Repo: "rust-analyzer",
|
||||
AssetPattern: "rust-analyzer-{target}.gz",
|
||||
Targets: map[string]string{
|
||||
"darwin/arm64": "aarch64-apple-darwin",
|
||||
"darwin/amd64": "x86_64-apple-darwin",
|
||||
"linux/amd64": "x86_64-unknown-linux-gnu",
|
||||
"linux/arm64": "aarch64-unknown-linux-gnu",
|
||||
},
|
||||
}, r.logger), nil
|
||||
case "python":
|
||||
return NewNpmStrategy(r.binDir, "pyright-langserver", []string{"pyright"}, r.logger), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("no installer for language: %s", language)
|
||||
}
|
||||
}
|
||||
|
||||
// BinaryPath checks if a language server binary is installed.
|
||||
// It checks the system PATH, the Kandev bin directory, and Go-specific paths.
|
||||
func (r *Registry) BinaryPath(language string) (string, error) {
|
||||
binary, err := binaryName(language)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Check system PATH first
|
||||
if p, err := exec.LookPath(binary); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Check Kandev bin directory (npm node_modules/.bin/)
|
||||
npmBinPath := filepath.Join(r.binDir, "node_modules", ".bin", binary)
|
||||
if _, err := os.Stat(npmBinPath); err == nil {
|
||||
return npmBinPath, nil
|
||||
}
|
||||
|
||||
// Check Kandev bin directory (direct binary)
|
||||
directPath := filepath.Join(r.binDir, binary)
|
||||
if _, err := os.Stat(directPath); err == nil {
|
||||
return directPath, nil
|
||||
}
|
||||
|
||||
// Check Go-specific paths for Go binaries
|
||||
if language == "go" {
|
||||
if p, err := findGoBinary(binary); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%s not found", binary)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
func testLogger() *logger.Logger {
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{
|
||||
Level: "error",
|
||||
Format: "json",
|
||||
OutputPath: os.DevNull,
|
||||
})
|
||||
return log
|
||||
}
|
||||
|
||||
func TestSupportedLanguages(t *testing.T) {
|
||||
langs := SupportedLanguages()
|
||||
expected := []string{"typescript", "go", "rust", "python"}
|
||||
for _, lang := range expected {
|
||||
if _, ok := langs[lang]; !ok {
|
||||
t.Errorf("expected %q in SupportedLanguages()", lang)
|
||||
}
|
||||
}
|
||||
if _, ok := langs["java"]; ok {
|
||||
t.Error("unexpected language 'java' in SupportedLanguages()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
language string
|
||||
want bool
|
||||
}{
|
||||
{"typescript", true},
|
||||
{"go", true},
|
||||
{"rust", true},
|
||||
{"python", true},
|
||||
{"java", false},
|
||||
{"", false},
|
||||
{"ruby", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := IsSupported(tc.language); got != tc.want {
|
||||
t.Errorf("IsSupported(%q) = %v, want %v", tc.language, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLspCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
language string
|
||||
wantBinary string
|
||||
wantArgs []string
|
||||
}{
|
||||
{"typescript", "typescript-language-server", []string{"--stdio"}},
|
||||
{"go", "gopls", []string{"serve"}},
|
||||
{"rust", "rust-analyzer", nil},
|
||||
{"python", "pyright-langserver", []string{"--stdio"}},
|
||||
{"unknown", "", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
binary, args := LspCommand(tc.language)
|
||||
if binary != tc.wantBinary {
|
||||
t.Errorf("LspCommand(%q) binary = %q, want %q", tc.language, binary, tc.wantBinary)
|
||||
}
|
||||
if len(args) != len(tc.wantArgs) {
|
||||
t.Errorf("LspCommand(%q) args = %v, want %v", tc.language, args, tc.wantArgs)
|
||||
} else {
|
||||
for i := range args {
|
||||
if args[i] != tc.wantArgs[i] {
|
||||
t.Errorf("LspCommand(%q) args[%d] = %q, want %q", tc.language, i, args[i], tc.wantArgs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryName(t *testing.T) {
|
||||
tests := []struct {
|
||||
language string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"typescript", "typescript-language-server", false},
|
||||
{"go", "gopls", false},
|
||||
{"rust", "rust-analyzer", false},
|
||||
{"python", "pyright-langserver", false},
|
||||
{"java", "", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := binaryName(tc.language)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("binaryName(%q) error = %v, wantErr %v", tc.language, err, tc.wantErr)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("binaryName(%q) = %q, want %q", tc.language, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyFor(t *testing.T) {
|
||||
r := NewRegistry(testLogger())
|
||||
|
||||
// Supported languages should return a strategy
|
||||
for _, lang := range []string{"typescript", "go", "rust", "python"} {
|
||||
s, err := r.StrategyFor(lang)
|
||||
if err != nil {
|
||||
t.Errorf("StrategyFor(%q) returned error: %v", lang, err)
|
||||
continue
|
||||
}
|
||||
if s == nil {
|
||||
t.Errorf("StrategyFor(%q) returned nil strategy", lang)
|
||||
continue
|
||||
}
|
||||
if s.Name() == "" {
|
||||
t.Errorf("StrategyFor(%q).Name() is empty", lang)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsupported language should return error
|
||||
_, err := r.StrategyFor("java")
|
||||
if err == nil {
|
||||
t.Error("StrategyFor(\"java\") should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryPath_InPATH(t *testing.T) {
|
||||
// "ls" should always be in PATH
|
||||
r := NewRegistry(testLogger())
|
||||
|
||||
// Override the languages map temporarily — we can't do that directly,
|
||||
// so we test that BinaryPath returns an error for a language whose binary
|
||||
// is not installed at all.
|
||||
_, err := r.BinaryPath("rust")
|
||||
// We can't guarantee rust-analyzer is installed, but we can verify
|
||||
// the function doesn't panic and returns a valid result or error
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestBinaryPath_InBinDir(t *testing.T) {
|
||||
// Create a temp bin directory with a fake binary
|
||||
tmpDir := t.TempDir()
|
||||
fakeBinary := filepath.Join(tmpDir, "node_modules", ".bin", "typescript-language-server")
|
||||
if err := os.MkdirAll(filepath.Dir(fakeBinary), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &Registry{
|
||||
binDir: tmpDir,
|
||||
logger: testLogger(),
|
||||
}
|
||||
|
||||
p, err := r.BinaryPath("typescript")
|
||||
if err != nil {
|
||||
// Only fail if it's not in PATH either
|
||||
if _, lookErr := exec.LookPath("typescript-language-server"); lookErr != nil {
|
||||
t.Errorf("BinaryPath(\"typescript\") error = %v (expected to find in binDir)", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if p == "" {
|
||||
t.Error("BinaryPath(\"typescript\") returned empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryPath_DirectBinary(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fakeBinary := filepath.Join(tmpDir, "rust-analyzer")
|
||||
if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &Registry{
|
||||
binDir: tmpDir,
|
||||
logger: testLogger(),
|
||||
}
|
||||
|
||||
p, err := r.BinaryPath("rust")
|
||||
if err != nil {
|
||||
if _, lookErr := exec.LookPath("rust-analyzer"); lookErr != nil {
|
||||
t.Errorf("BinaryPath(\"rust\") error = %v (expected to find direct binary)", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if p == "" {
|
||||
t.Error("BinaryPath(\"rust\") returned empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryPath_NotFound(t *testing.T) {
|
||||
// Use empty binDir so nothing is found there
|
||||
r := &Registry{
|
||||
binDir: t.TempDir(),
|
||||
logger: testLogger(),
|
||||
}
|
||||
|
||||
// Use a language whose binary is unlikely to be in PATH on CI
|
||||
// We test the error case by overriding PATH
|
||||
origPath := os.Getenv("PATH")
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
defer func() { _ = os.Setenv("PATH", origPath) }()
|
||||
|
||||
_, err := r.BinaryPath("rust")
|
||||
if err == nil {
|
||||
t.Error("BinaryPath(\"rust\") should return error when binary not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryPath_UnsupportedLanguage(t *testing.T) {
|
||||
r := NewRegistry(testLogger())
|
||||
_, err := r.BinaryPath("java")
|
||||
if err == nil {
|
||||
t.Error("BinaryPath(\"java\") should return error for unsupported language")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGoBinary(t *testing.T) {
|
||||
// Test with GOBIN set to a temp directory containing a fake binary
|
||||
tmpDir := t.TempDir()
|
||||
fakeBinary := filepath.Join(tmpDir, "gopls")
|
||||
if err := os.WriteFile(fakeBinary, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("GOBIN", tmpDir)
|
||||
t.Setenv("GOPATH", "")
|
||||
|
||||
p, err := findGoBinary("gopls")
|
||||
if err != nil {
|
||||
t.Errorf("findGoBinary(\"gopls\") error = %v", err)
|
||||
}
|
||||
if p != fakeBinary {
|
||||
t.Errorf("findGoBinary(\"gopls\") = %q, want %q", p, fakeBinary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGoBinary_NotFound(t *testing.T) {
|
||||
t.Setenv("GOBIN", t.TempDir())
|
||||
t.Setenv("GOPATH", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
_, err := findGoBinary("nonexistent-binary")
|
||||
if err == nil {
|
||||
t.Error("findGoBinary(\"nonexistent-binary\") should return error")
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,10 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
|
||||
DefaultEditorID: req.DefaultEditorID,
|
||||
EnablePreviewOnClick: req.EnablePreviewOnClick,
|
||||
ChatSubmitKey: req.ChatSubmitKey,
|
||||
ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll,
|
||||
ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll,
|
||||
LspAutoStartLanguages: req.LspAutoStartLanguages,
|
||||
LspAutoInstallLanguages: req.LspAutoInstallLanguages,
|
||||
LspServerConfigs: req.LspServerConfigs,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.UserSettingsResponse{}, err
|
||||
|
||||
@@ -22,9 +22,12 @@ type UserSettingsDTO struct {
|
||||
PreferredShell string `json:"preferred_shell"`
|
||||
DefaultEditorID string `json:"default_editor_id"`
|
||||
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
|
||||
ChatSubmitKey string `json:"chat_submit_key"`
|
||||
ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ChatSubmitKey string `json:"chat_submit_key"`
|
||||
ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
|
||||
LspAutoStartLanguages []string `json:"lsp_auto_start_languages"`
|
||||
LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
|
||||
LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
@@ -51,7 +54,10 @@ type UpdateUserSettingsRequest struct {
|
||||
DefaultEditorID *string `json:"default_editor_id,omitempty"`
|
||||
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
|
||||
ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
|
||||
LspAutoStartLanguages *[]string `json:"lsp_auto_start_languages,omitempty"`
|
||||
LspAutoInstallLanguages *[]string `json:"lsp_auto_install_languages,omitempty"`
|
||||
LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"`
|
||||
}
|
||||
|
||||
func FromUser(user *models.User) UserDTO {
|
||||
@@ -74,7 +80,10 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
|
||||
DefaultEditorID: settings.DefaultEditorID,
|
||||
EnablePreviewOnClick: settings.EnablePreviewOnClick,
|
||||
ChatSubmitKey: settings.ChatSubmitKey,
|
||||
ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll,
|
||||
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
|
||||
ReviewAutoMarkOnScroll: settings.ReviewAutoMarkOnScroll,
|
||||
LspAutoStartLanguages: settings.LspAutoStartLanguages,
|
||||
LspAutoInstallLanguages: settings.LspAutoInstallLanguages,
|
||||
LspServerConfigs: settings.LspServerConfigs,
|
||||
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,35 +63,13 @@ func (h *Handlers) httpGetUserSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type httpUpdateUserSettingsRequest struct {
|
||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||
BoardID *string `json:"board_id,omitempty"`
|
||||
RepositoryIDs *[]string `json:"repository_ids,omitempty"`
|
||||
InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
|
||||
PreferredShell *string `json:"preferred_shell,omitempty"`
|
||||
DefaultEditorID *string `json:"default_editor_id,omitempty"`
|
||||
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
|
||||
ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
|
||||
var body httpUpdateUserSettingsRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
var req dto.UpdateUserSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateUserSettings(c.Request.Context(), dto.UpdateUserSettingsRequest{
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
BoardID: body.BoardID,
|
||||
RepositoryIDs: body.RepositoryIDs,
|
||||
InitialSetupComplete: body.InitialSetupComplete,
|
||||
PreferredShell: body.PreferredShell,
|
||||
DefaultEditorID: body.DefaultEditorID,
|
||||
EnablePreviewOnClick: body.EnablePreviewOnClick,
|
||||
ChatSubmitKey: body.ChatSubmitKey,
|
||||
ReviewAutoMarkOnScroll: body.ReviewAutoMarkOnScroll,
|
||||
})
|
||||
resp, err := h.controller.UpdateUserSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update user settings", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user settings"})
|
||||
@@ -108,34 +86,12 @@ func (h *Handlers) wsGetUser(ctx context.Context, msg *ws.Message) (*ws.Message,
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsUpdateUserSettingsRequest struct {
|
||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||
BoardID *string `json:"board_id,omitempty"`
|
||||
RepositoryIDs *[]string `json:"repository_ids,omitempty"`
|
||||
InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
|
||||
PreferredShell *string `json:"preferred_shell,omitempty"`
|
||||
DefaultEditorID *string `json:"default_editor_id,omitempty"`
|
||||
EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"`
|
||||
ChatSubmitKey *string `json:"chat_submit_key,omitempty"`
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsUpdateUserSettingsRequest
|
||||
var req dto.UpdateUserSettingsRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
resp, err := h.controller.UpdateUserSettings(ctx, dto.UpdateUserSettingsRequest{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
BoardID: req.BoardID,
|
||||
RepositoryIDs: req.RepositoryIDs,
|
||||
InitialSetupComplete: req.InitialSetupComplete,
|
||||
PreferredShell: req.PreferredShell,
|
||||
DefaultEditorID: req.DefaultEditorID,
|
||||
EnablePreviewOnClick: req.EnablePreviewOnClick,
|
||||
ChatSubmitKey: req.ChatSubmitKey,
|
||||
ReviewAutoMarkOnScroll: req.ReviewAutoMarkOnScroll,
|
||||
})
|
||||
resp, err := h.controller.UpdateUserSettings(ctx, req)
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ type UserSettings struct {
|
||||
DefaultEditorID string `json:"default_editor_id"`
|
||||
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
|
||||
ChatSubmitKey string `json:"chat_submit_key"` // "enter" | "cmd_enter"
|
||||
ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ReviewAutoMarkOnScroll bool `json:"review_auto_mark_on_scroll"`
|
||||
LspAutoStartLanguages []string `json:"lsp_auto_start_languages"`
|
||||
LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
|
||||
LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/lsp/installer"
|
||||
"github.com/kandev/kandev/internal/user/models"
|
||||
"github.com/kandev/kandev/internal/user/store"
|
||||
"go.uber.org/zap"
|
||||
@@ -32,7 +34,10 @@ type UpdateUserSettingsRequest struct {
|
||||
DefaultEditorID *string
|
||||
EnablePreviewOnClick *bool
|
||||
ChatSubmitKey *string
|
||||
ReviewAutoMarkOnScroll *bool
|
||||
ReviewAutoMarkOnScroll *bool
|
||||
LspAutoStartLanguages *[]string
|
||||
LspAutoInstallLanguages *[]string
|
||||
LspServerConfigs *map[string]map[string]interface{}
|
||||
}
|
||||
|
||||
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
|
||||
@@ -105,6 +110,21 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting
|
||||
if req.ReviewAutoMarkOnScroll != nil {
|
||||
settings.ReviewAutoMarkOnScroll = *req.ReviewAutoMarkOnScroll
|
||||
}
|
||||
if req.LspAutoStartLanguages != nil {
|
||||
if err := validateLSPLanguages(*req.LspAutoStartLanguages); err != nil {
|
||||
return nil, fmt.Errorf("lsp_auto_start_languages: %w", err)
|
||||
}
|
||||
settings.LspAutoStartLanguages = *req.LspAutoStartLanguages
|
||||
}
|
||||
if req.LspAutoInstallLanguages != nil {
|
||||
if err := validateLSPLanguages(*req.LspAutoInstallLanguages); err != nil {
|
||||
return nil, fmt.Errorf("lsp_auto_install_languages: %w", err)
|
||||
}
|
||||
settings.LspAutoInstallLanguages = *req.LspAutoInstallLanguages
|
||||
}
|
||||
if req.LspServerConfigs != nil {
|
||||
settings.LspServerConfigs = *req.LspServerConfigs
|
||||
}
|
||||
settings.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpsertUserSettings(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
@@ -127,7 +147,10 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
|
||||
"default_editor_id": settings.DefaultEditorID,
|
||||
"enable_preview_on_click": settings.EnablePreviewOnClick,
|
||||
"chat_submit_key": settings.ChatSubmitKey,
|
||||
"review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
|
||||
"review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
|
||||
"lsp_auto_start_languages": settings.LspAutoStartLanguages,
|
||||
"lsp_auto_install_languages": settings.LspAutoInstallLanguages,
|
||||
"lsp_server_configs": settings.LspServerConfigs,
|
||||
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
|
||||
@@ -135,6 +158,16 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
|
||||
}
|
||||
}
|
||||
|
||||
func validateLSPLanguages(langs []string) error {
|
||||
supported := installer.SupportedLanguages()
|
||||
for _, lang := range langs {
|
||||
if _, ok := supported[lang]; !ok {
|
||||
return fmt.Errorf("unsupported language: %s", lang)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ClearDefaultEditorID(ctx context.Context, editorID string) error {
|
||||
if editorID == "" {
|
||||
return nil
|
||||
|
||||
@@ -111,16 +111,31 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
|
||||
if settings.CreatedAt.IsZero() {
|
||||
settings.CreatedAt = settings.UpdatedAt
|
||||
}
|
||||
lspAutoStart := settings.LspAutoStartLanguages
|
||||
if lspAutoStart == nil {
|
||||
lspAutoStart = []string{}
|
||||
}
|
||||
lspAutoInstall := settings.LspAutoInstallLanguages
|
||||
if lspAutoInstall == nil {
|
||||
lspAutoInstall = []string{}
|
||||
}
|
||||
lspServerConfigs := settings.LspServerConfigs
|
||||
if lspServerConfigs == nil {
|
||||
lspServerConfigs = map[string]map[string]interface{}{}
|
||||
}
|
||||
settingsPayload, err := json.Marshal(map[string]interface{}{
|
||||
"workspace_id": settings.WorkspaceID,
|
||||
"board_id": settings.BoardID,
|
||||
"repository_ids": settings.RepositoryIDs,
|
||||
"initial_setup_complete": settings.InitialSetupComplete,
|
||||
"preferred_shell": settings.PreferredShell,
|
||||
"default_editor_id": settings.DefaultEditorID,
|
||||
"enable_preview_on_click": settings.EnablePreviewOnClick,
|
||||
"chat_submit_key": settings.ChatSubmitKey,
|
||||
"workspace_id": settings.WorkspaceID,
|
||||
"board_id": settings.BoardID,
|
||||
"repository_ids": settings.RepositoryIDs,
|
||||
"initial_setup_complete": settings.InitialSetupComplete,
|
||||
"preferred_shell": settings.PreferredShell,
|
||||
"default_editor_id": settings.DefaultEditorID,
|
||||
"enable_preview_on_click": settings.EnablePreviewOnClick,
|
||||
"chat_submit_key": settings.ChatSubmitKey,
|
||||
"review_auto_mark_on_scroll": settings.ReviewAutoMarkOnScroll,
|
||||
"lsp_auto_start_languages": lspAutoStart,
|
||||
"lsp_auto_install_languages": lspAutoInstall,
|
||||
"lsp_server_configs": lspServerConfigs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -130,13 +145,17 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
|
||||
SET settings = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, string(settingsPayload), settings.UpdatedAt, settings.UserID)
|
||||
if err == nil {
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("user not found: %s", settings.UserID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("user not found: %s", settings.UserID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) {
|
||||
@@ -159,15 +178,18 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
return settings, nil
|
||||
}
|
||||
var payload struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
RepositoryIDs []string `json:"repository_ids"`
|
||||
InitialSetupComplete bool `json:"initial_setup_complete"`
|
||||
PreferredShell string `json:"preferred_shell"`
|
||||
DefaultEditorID string `json:"default_editor_id"`
|
||||
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
|
||||
ChatSubmitKey string `json:"chat_submit_key"`
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
RepositoryIDs []string `json:"repository_ids"`
|
||||
InitialSetupComplete bool `json:"initial_setup_complete"`
|
||||
PreferredShell string `json:"preferred_shell"`
|
||||
DefaultEditorID string `json:"default_editor_id"`
|
||||
EnablePreviewOnClick bool `json:"enable_preview_on_click"`
|
||||
ChatSubmitKey string `json:"chat_submit_key"`
|
||||
ReviewAutoMarkOnScroll *bool `json:"review_auto_mark_on_scroll"`
|
||||
LspAutoStartLanguages []string `json:"lsp_auto_start_languages"`
|
||||
LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"`
|
||||
LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -190,5 +212,14 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
} else {
|
||||
settings.ReviewAutoMarkOnScroll = true
|
||||
}
|
||||
settings.LspAutoStartLanguages = payload.LspAutoStartLanguages
|
||||
if settings.LspAutoStartLanguages == nil {
|
||||
settings.LspAutoStartLanguages = []string{}
|
||||
}
|
||||
settings.LspAutoInstallLanguages = payload.LspAutoInstallLanguages
|
||||
if settings.LspAutoInstallLanguages == nil {
|
||||
settings.LspAutoInstallLanguages = []string{}
|
||||
}
|
||||
settings.LspServerConfigs = payload.LspServerConfigs
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user