feat: improve editors (#226)

This commit is contained in:
Carlos Florêncio
2026-02-16 09:55:04 +00:00
committed by GitHub
parent 95f1b2e13b
commit c4ef2d9ba1
68 changed files with 7389 additions and 2605 deletions
+3 -2
View File
@@ -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
+15 -6
View File
@@ -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)
}
+6 -3
View File
@@ -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"`
}
+35 -2
View File
@@ -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
+54 -23
View File
@@ -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
}