From c4ef2d9ba1602c665acfee021d5456f74ac528ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 16 Feb 2026 09:55:04 +0000 Subject: [PATCH] feat: improve editors (#226) --- apps/backend/cmd/kandev/gateway.go | 5 +- .../internal/gateway/websocket/lsp_handler.go | 426 ++++++ .../gateway/websocket/lsp_handler_test.go | 176 +++ .../internal/gateway/websocket/provider.go | 4 +- .../internal/gateway/websocket/setup.go | 12 + .../lsp/installer/github_release_strategy.go | 127 ++ .../lsp/installer/go_install_strategy.go | 57 + .../internal/lsp/installer/installer.go | 16 + .../internal/lsp/installer/npm_strategy.go | 69 + .../internal/lsp/installer/registry.go | 164 +++ .../internal/lsp/installer/registry_test.go | 255 ++++ .../internal/user/controller/controller.go | 5 +- apps/backend/internal/user/dto/dto.go | 21 +- .../internal/user/handlers/handlers.go | 54 +- apps/backend/internal/user/models/models.go | 9 +- apps/backend/internal/user/service/service.go | 37 +- apps/backend/internal/user/store/sqlite.go | 77 +- apps/pnpm-lock.yaml | 1178 +++++------------ apps/web/app/globals.css | 561 ++++---- apps/web/app/page.tsx | 3 + apps/web/app/s/[sessionId]/page.tsx | 3 + apps/web/components/diff/diff-view-inline.tsx | 4 +- .../components/diff/diff-viewer-resolver.tsx | 39 + apps/web/components/diff/diff-viewer.tsx | 6 +- apps/web/components/diff/file-diff-viewer.tsx | 2 +- apps/web/components/diff/index.ts | 10 +- .../codemirror/codemirror-code-block.tsx | 80 ++ .../codemirror/codemirror-code-editor.tsx | 649 +++++++++ .../editors/file-actions-dropdown.tsx | 135 ++ .../editors/monaco/builtin-providers.ts | 16 + .../editors/monaco/monaco-code-block.tsx | 82 ++ .../editors/monaco/monaco-code-editor.tsx | 719 ++++++++++ .../editors/monaco/monaco-diff-viewer.tsx | 555 ++++++++ .../components/editors/monaco/monaco-init.ts | 108 ++ .../editors/monaco/monaco-inline-diff.tsx | 92 ++ .../editors/monaco/monaco-loader.ts | 116 ++ .../editors/pierre/pierre-diff-viewer.tsx | 5 + .../tiptap/tiptap-comment-highlight.ts | 221 ++++ .../tiptap/tiptap-mermaid-extension.ts | 104 ++ .../editors/tiptap/tiptap-plan-editor.tsx | 236 ++++ apps/web/components/review/review-dialog.tsx | 3 + .../components/review/review-diff-list.tsx | 48 +- .../components/settings/editors-settings.tsx | 497 +++++-- .../task/chat/messages/code-block.tsx | 79 +- .../task/chat/messages/diff-view-block.tsx | 10 +- .../task/dockview-desktop-layout.tsx | 4 + .../components/task/file-editor-content.tsx | 639 +-------- apps/web/components/task/markdown-editor.tsx | 605 --------- .../components/task/task-changes-panel.tsx | 7 + apps/web/components/task/task-plan-panel.tsx | 10 +- .../web/hooks/domains/settings/use-editors.ts | 3 + apps/web/hooks/use-editor-resolver.ts | 5 + apps/web/hooks/use-file-editors.ts | 14 + apps/web/hooks/use-lsp-file-opener.ts | 94 ++ apps/web/hooks/use-lsp.ts | 73 + apps/web/hooks/use-user-display-settings.ts | 9 + apps/web/lib/api/domains/settings-api.ts | 3 + apps/web/lib/editor/language-map.ts | 194 +++ apps/web/lib/lsp/lsp-client-manager.ts | 1065 +++++++++++++++ apps/web/lib/state/editor-resolver-store.ts | 51 + .../state/slices/settings/settings-slice.ts | 3 + apps/web/lib/state/slices/settings/types.ts | 3 + apps/web/lib/theme/editor-theme.ts | 107 ++ apps/web/lib/types/backend.ts | 2 + apps/web/lib/types/http.ts | 3 + apps/web/lib/ws/handlers/users.ts | 2 + apps/web/next.config.ts | 1 + apps/web/package.json | 22 +- 68 files changed, 7389 insertions(+), 2605 deletions(-) create mode 100644 apps/backend/internal/gateway/websocket/lsp_handler.go create mode 100644 apps/backend/internal/gateway/websocket/lsp_handler_test.go create mode 100644 apps/backend/internal/lsp/installer/github_release_strategy.go create mode 100644 apps/backend/internal/lsp/installer/go_install_strategy.go create mode 100644 apps/backend/internal/lsp/installer/installer.go create mode 100644 apps/backend/internal/lsp/installer/npm_strategy.go create mode 100644 apps/backend/internal/lsp/installer/registry.go create mode 100644 apps/backend/internal/lsp/installer/registry_test.go create mode 100644 apps/web/components/diff/diff-viewer-resolver.tsx create mode 100644 apps/web/components/editors/codemirror/codemirror-code-block.tsx create mode 100644 apps/web/components/editors/codemirror/codemirror-code-editor.tsx create mode 100644 apps/web/components/editors/file-actions-dropdown.tsx create mode 100644 apps/web/components/editors/monaco/builtin-providers.ts create mode 100644 apps/web/components/editors/monaco/monaco-code-block.tsx create mode 100644 apps/web/components/editors/monaco/monaco-code-editor.tsx create mode 100644 apps/web/components/editors/monaco/monaco-diff-viewer.tsx create mode 100644 apps/web/components/editors/monaco/monaco-init.ts create mode 100644 apps/web/components/editors/monaco/monaco-inline-diff.tsx create mode 100644 apps/web/components/editors/monaco/monaco-loader.ts create mode 100644 apps/web/components/editors/pierre/pierre-diff-viewer.tsx create mode 100644 apps/web/components/editors/tiptap/tiptap-comment-highlight.ts create mode 100644 apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts create mode 100644 apps/web/components/editors/tiptap/tiptap-plan-editor.tsx delete mode 100644 apps/web/components/task/markdown-editor.tsx create mode 100644 apps/web/hooks/use-editor-resolver.ts create mode 100644 apps/web/hooks/use-lsp-file-opener.ts create mode 100644 apps/web/hooks/use-lsp.ts create mode 100644 apps/web/lib/editor/language-map.ts create mode 100644 apps/web/lib/lsp/lsp-client-manager.ts create mode 100644 apps/web/lib/state/editor-resolver-store.ts create mode 100644 apps/web/lib/theme/editor-theme.ts diff --git a/apps/backend/cmd/kandev/gateway.go b/apps/backend/cmd/kandev/gateway.go index fa56d15760..46d775926d 100644 --- a/apps/backend/cmd/kandev/gateway.go +++ b/apps/backend/cmd/kandev/gateway.go @@ -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) diff --git a/apps/backend/internal/gateway/websocket/lsp_handler.go b/apps/backend/internal/gateway/websocket/lsp_handler.go new file mode 100644 index 0000000000..a6698d59d0 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/lsp_handler.go @@ -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) +} diff --git a/apps/backend/internal/gateway/websocket/lsp_handler_test.go b/apps/backend/internal/gateway/websocket/lsp_handler_test.go new file mode 100644 index 0000000000..400f7280d8 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/lsp_handler_test.go @@ -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 + } +} diff --git a/apps/backend/internal/gateway/websocket/provider.go b/apps/backend/internal/gateway/websocket/provider.go index dee3dd6dbf..d693514bfc 100644 --- a/apps/backend/internal/gateway/websocket/provider.go +++ b/apps/backend/internal/gateway/websocket/provider.go @@ -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 } diff --git a/apps/backend/internal/gateway/websocket/setup.go b/apps/backend/internal/gateway/websocket/setup.go index 800afcdfae..23cd9970b3 100644 --- a/apps/backend/internal/gateway/websocket/setup.go +++ b/apps/backend/internal/gateway/websocket/setup.go @@ -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) + } } diff --git a/apps/backend/internal/lsp/installer/github_release_strategy.go b/apps/backend/internal/lsp/installer/github_release_strategy.go new file mode 100644 index 0000000000..457625fe69 --- /dev/null +++ b/apps/backend/internal/lsp/installer/github_release_strategy.go @@ -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 +} diff --git a/apps/backend/internal/lsp/installer/go_install_strategy.go b/apps/backend/internal/lsp/installer/go_install_strategy.go new file mode 100644 index 0000000000..cf0bf853f1 --- /dev/null +++ b/apps/backend/internal/lsp/installer/go_install_strategy.go @@ -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 +} diff --git a/apps/backend/internal/lsp/installer/installer.go b/apps/backend/internal/lsp/installer/installer.go new file mode 100644 index 0000000000..cf041e3260 --- /dev/null +++ b/apps/backend/internal/lsp/installer/installer.go @@ -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 +} diff --git a/apps/backend/internal/lsp/installer/npm_strategy.go b/apps/backend/internal/lsp/installer/npm_strategy.go new file mode 100644 index 0000000000..8b93366a04 --- /dev/null +++ b/apps/backend/internal/lsp/installer/npm_strategy.go @@ -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 +} diff --git a/apps/backend/internal/lsp/installer/registry.go b/apps/backend/internal/lsp/installer/registry.go new file mode 100644 index 0000000000..1d99d38e8d --- /dev/null +++ b/apps/backend/internal/lsp/installer/registry.go @@ -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) +} diff --git a/apps/backend/internal/lsp/installer/registry_test.go b/apps/backend/internal/lsp/installer/registry_test.go new file mode 100644 index 0000000000..0cbe63ba84 --- /dev/null +++ b/apps/backend/internal/lsp/installer/registry_test.go @@ -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") + } +} diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 40f1651d85..9c6d58bcad 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -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 diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index bcabbb86d7..e2b3c940e3 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -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), } } diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go index 159e271668..204abaa82a 100644 --- a/apps/backend/internal/user/handlers/handlers.go +++ b/apps/backend/internal/user/handlers/handlers.go @@ -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) } diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 88da4e7a04..96905b7974 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -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"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 5f0cfff639..cb44849c63 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -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 diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 016a744bb1..06800633bf 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -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 } diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 67c6691806..76a180a06a 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -230,33 +230,9 @@ importers: '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 - '@milkdown/core': - specifier: ^7.18.0 - version: 7.18.0 - '@milkdown/ctx': - specifier: ^7.18.0 - version: 7.18.0 - '@milkdown/plugin-listener': - specifier: ^7.18.0 - version: 7.18.0 - '@milkdown/preset-commonmark': - specifier: ^7.18.0 - version: 7.18.0 - '@milkdown/preset-gfm': - specifier: ^7.18.0 - version: 7.18.0 - '@milkdown/prose': - specifier: ^7.18.0 - version: 7.18.0 - '@milkdown/react': - specifier: ^7.18.0 - version: 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) - '@milkdown/theme-nord': - specifier: ^7.18.0 - version: 7.18.0 - '@milkdown/utils': - specifier: ^7.18.0 - version: 7.18.0 + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@pierre/diffs': specifier: ^1.0.10 version: 1.0.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -272,6 +248,9 @@ importers: '@tiptap/core': specifier: ^3.19.0 version: 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-bubble-menu': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@tiptap/extension-code': specifier: ^3.19.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) @@ -287,6 +266,12 @@ importers: '@tiptap/extension-history': specifier: ^3.19.0 version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-image': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-link': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@tiptap/extension-mention': specifier: ^3.19.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) @@ -296,6 +281,24 @@ importers: '@tiptap/extension-placeholder': specifier: ^3.19.0 version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-table': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-table-cell': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-table-header': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-table-row': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-task-item': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-task-list': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) '@tiptap/extension-text': specifier: ^3.19.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) @@ -305,6 +308,9 @@ importers: '@tiptap/react': specifier: ^3.19.0 version: 3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tiptap/starter-kit': + specifier: ^3.19.0 + version: 3.19.0 '@tiptap/suggestion': specifier: ^3.19.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) @@ -365,6 +371,9 @@ importers: mermaid: specifier: ^11.12.2 version: 11.12.2 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 next: specifier: 16.1.1 version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -410,6 +419,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.4.0 + tiptap-markdown: + specifier: ^0.9.0 + version: 0.9.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -648,9 +660,6 @@ packages: '@codemirror/commands@6.10.1': resolution: {integrity: sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==} - '@codemirror/lang-angular@0.1.4': - resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} - '@codemirror/lang-cpp@6.0.3': resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} @@ -669,57 +678,24 @@ packages: '@codemirror/lang-javascript@6.2.4': resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} - '@codemirror/lang-jinja@6.0.0': - resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==} - '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} - '@codemirror/lang-less@6.0.2': - resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} - - '@codemirror/lang-liquid@6.3.1': - resolution: {integrity: sha512-S/jE/D7iij2Pu70AC65ME6AYWxOOcX20cSJvaPgY5w7m2sfxsArAcUAuUgm/CZCVmqoi9KiOlS7gj/gyLipABw==} - '@codemirror/lang-markdown@6.5.0': resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} - '@codemirror/lang-php@6.0.2': - resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} - '@codemirror/lang-python@6.2.1': resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} '@codemirror/lang-rust@6.0.2': resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} - '@codemirror/lang-sass@6.0.2': - resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} - - '@codemirror/lang-sql@6.10.0': - resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} - - '@codemirror/lang-vue@0.1.3': - resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} - - '@codemirror/lang-wast@6.0.2': - resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} - - '@codemirror/lang-xml@6.1.0': - resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} - '@codemirror/lang-yaml@6.1.2': resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} - '@codemirror/language-data@6.5.2': - resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} - '@codemirror/language@6.12.1': resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} - '@codemirror/legacy-modes@6.5.2': - resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} - '@codemirror/lint@6.9.2': resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} @@ -1387,21 +1363,12 @@ packages: '@lezer/markdown@1.6.3': resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} - '@lezer/php@1.0.5': - resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} - '@lezer/python@1.1.18': resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} '@lezer/rust@1.0.2': resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} - '@lezer/sass@1.1.0': - resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} - - '@lezer/xml@1.0.6': - resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} - '@lezer/yaml@1.0.3': resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==} @@ -1411,82 +1378,6 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} - '@milkdown/components@7.18.0': - resolution: {integrity: sha512-Zu/GMqy1byyxul/+/RWcpe02b7luhtW1SfTYNFZnaWPvIap5M9vG7pFeQNRqJe5cbfKI+bvW8Ubyb5BG2kb9Ug==} - peerDependencies: - '@codemirror/language': ^6 - '@codemirror/state': 6.5.3 - '@codemirror/view': 6.39.10 - - '@milkdown/core@7.18.0': - resolution: {integrity: sha512-BUVR/72XwrtM3qHTTtXtmCtGfuaAexvSxosYIXw7d6ElbLiLIe3bOXjGwwgLHW3xsq23VKmYMsFqWLUFt6uGDQ==} - - '@milkdown/crepe@7.18.0': - resolution: {integrity: sha512-GcHW6Use0MCRvFg6RQVN5EaeyMlxFxDEGbGwqApnBblxZi5PV9nlAAn0AfOhYvFHSDkQ3rQa5fuHQ0Bd0KobQQ==} - - '@milkdown/ctx@7.18.0': - resolution: {integrity: sha512-F+t8U/akpY7Vw+KD+z32Itr6lrVLAGTVO79DN436BnFK/J9kiPzTRfTet6fMOj3NlwO/24lUluiPZd7qbCmn8A==} - - '@milkdown/exception@7.18.0': - resolution: {integrity: sha512-sAyi4IqdChh4+lpgucmgDZNGjYuIRvJimZeMj0SdfdeHDABan5Nco3X+5yOGaBq1z9QOJG90+vEcEvUASHBmFw==} - - '@milkdown/kit@7.18.0': - resolution: {integrity: sha512-6C8c/bU+3Md/rlZFTqMmdVen2xSC80LYBOZ/G4+W39gsV7x/ux/HRdd8xk75a4IrHKgq6EJpGJ1yH8BvT7P+1A==} - - '@milkdown/plugin-block@7.18.0': - resolution: {integrity: sha512-+x00o7Vh5nQesw4j6QwtwCThdjSiH/jUvAzrTpwr8xvRmQnmztdfdJhPHxp48pK/sIEct3660HWuwDpdeAlmRw==} - - '@milkdown/plugin-clipboard@7.18.0': - resolution: {integrity: sha512-Gnp+GqkoLS1pKG9S2QfdvZQjfoJosQek5Yv5zOIj5X388yfVlguKNtCwnDCJKVEVws9e8PnhfPBmzr06713dZw==} - - '@milkdown/plugin-cursor@7.18.0': - resolution: {integrity: sha512-SsvFEeFMv1jrzVBnuAMyAwZzhjwCk4wmGjJEug41Ic+CT0YMUtVPJn5QVn7fjixR13kzkfaNDUPZ+sGNqIR2xw==} - - '@milkdown/plugin-history@7.18.0': - resolution: {integrity: sha512-hWM3rpad/THy267dXgEWRu9Arf+3j2KE8UN3jhqsUvVLZZ2ZetaPc2imHowJaLR8PwCb649+1RxL+IKrXizNKQ==} - - '@milkdown/plugin-indent@7.18.0': - resolution: {integrity: sha512-LAVMSsy6lWvy/QjvSazojUeW6v1lLFj5Fjv3YvqDNtP6/RSOIhHJs75aXbv92Kx43aRJnkh7EVy9Wu4OxSC70Q==} - - '@milkdown/plugin-listener@7.18.0': - resolution: {integrity: sha512-F2iPKdWYGJX5kMnmIeZeybQ5gZUwT/smNBbt/itPBn5cD4YRF1qmY/MxDs0+nvoN2NSxtEx5pHOtd5/E4mCf2A==} - - '@milkdown/plugin-slash@7.18.0': - resolution: {integrity: sha512-jBcaLswX1yKG97s0V1qFqk/0aR+LpWnTCHIrryNVRIRFYm7B6tITekkqwALlV2bqE1eykeN2j8yEyRQ63Wv05Q==} - - '@milkdown/plugin-tooltip@7.18.0': - resolution: {integrity: sha512-Z8WYSEFANhHPS2A8uMIcKGJ3vt0KKCJ80hffuJffudJT9FSIXieh1f8OKcKQuhcRHxRCRUApMcOOjOptiVaHvQ==} - - '@milkdown/plugin-trailing@7.18.0': - resolution: {integrity: sha512-AusCWoZSRfgsStdlmg+4sYZ08HLDDiHhesDCqiLCdo1bklNhzK/9q6gxdL1HP5xTn5a4xV9hUrI7E7M0JaKdug==} - - '@milkdown/plugin-upload@7.18.0': - resolution: {integrity: sha512-fsWwd6g6FX35Wg12KVE1Yu3wU8vM5hA567DufeHcik9LckdLJcZKf35JMJDUOAOkEdU3V91BKO47KUhBPFt1jA==} - - '@milkdown/preset-commonmark@7.18.0': - resolution: {integrity: sha512-L/F9vmhQKOjKJZTEEsKjDu/2KkMTDxBVQISk4w+j8KFWx9OpHBwqWqyHiDLTREbT7pJqLfyB96eXvfuMG4za5g==} - - '@milkdown/preset-gfm@7.18.0': - resolution: {integrity: sha512-NLfkd7HOaaMCMImXmBh8TX8KNkgKecM7YRHFEwb5D/SMLyBLyZs7lDfLEKPU9N52+vzgwMz8ceUSlCElmneTJg==} - - '@milkdown/prose@7.18.0': - resolution: {integrity: sha512-bRDfgVM6uKRaejvju/FWdQMryQc4kSSso+fnABUbvbCKitXnsgRPvclsddbt3J92anQwLRDWr/qotx1NcyDM1Q==} - - '@milkdown/react@7.18.0': - resolution: {integrity: sha512-hk7CN6YqhazUBOdY0Iyh3RjvRyjsl2vBsJyf54ua38hxmaAD13KbTnEWZs30OnryoP6cv9z74bHPMIc2UnSVIQ==} - peerDependencies: - react: '*' - react-dom: '*' - - '@milkdown/theme-nord@7.18.0': - resolution: {integrity: sha512-3t5Fb0Zwsmf2VDkhZm6U5C6/CWiU7UN+Z+/tpbOC1Stsid4CJ3y7j7m/3oRZlQyUEI07dHvAAOHqzyGoYl0FZw==} - - '@milkdown/transformer@7.18.0': - resolution: {integrity: sha512-AzTgqDktQw9nzgrpICjYNxScYwwnxmALPSyZ39Y0wNZJafi8QMVqLv4w2bhyYkxITXolPHdLAAsZXPKuMjrmNA==} - - '@milkdown/utils@7.18.0': - resolution: {integrity: sha512-+o/1sky+QwbS0Y92HthTupMFziJKhZUgF7IBS55Ft4Wjt63kX8PHaLC9KtewNawpzyM/CjPJ9ySCIa+C/06Bsg==} - '@modelcontextprotocol/sdk@1.25.2': resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==} engines: {node: '>=18'} @@ -1497,6 +1388,16 @@ packages: '@cfworker/json-schema': optional: true + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@mswjs/interceptors@0.40.0': resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} engines: {node: '>=18'} @@ -1586,9 +1487,6 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@ocavue/utils@1.3.1': - resolution: {integrity: sha512-jd8D6fUdkspMUYr2EE8mTj1GgPivXeHMyPeI2S9v7DEJcNN1DN2Wo/7xZ5H7vUycgfpr8ucv63aKh3NKURHc1Q==} - '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -2631,12 +2529,27 @@ packages: peerDependencies: '@tiptap/pm': ^3.19.0 + '@tiptap/extension-blockquote@3.19.0': + resolution: {integrity: sha512-y3UfqY9KD5XwWz3ndiiJ089Ij2QKeiXy/g1/tlAN/F1AaWsnkHEHMLxCP1BIqmMpwsX7rZjMLN7G5Lp7c9682A==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-bold@3.19.0': + resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/extension-bubble-menu@3.19.0': resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} peerDependencies: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-bullet-list@3.19.0': + resolution: {integrity: sha512-F9uNnqd0xkJbMmRxVI5RuVxwB9JaCH/xtRqOUNQZnRBt7IdAElCY+Dvb4hMCtiNv+enGM/RFGJuFHR9TxmI7rw==} + peerDependencies: + '@tiptap/extension-list': ^3.19.0 + '@tiptap/extension-code-block-lowlight@3.19.0': resolution: {integrity: sha512-P8O8i1J+XozEVA7bF/Ijwf/r1rVqrh1DBQ7dXxBcrUvLpIGyVjtxX228jBF/kD4kf2xOlphvjDhV2fLa8XOVsg==} peerDependencies: @@ -2662,6 +2575,11 @@ packages: peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-dropcursor@3.19.0': + resolution: {integrity: sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==} + peerDependencies: + '@tiptap/extensions': ^3.19.0 + '@tiptap/extension-floating-menu@3.19.0': resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} peerDependencies: @@ -2669,16 +2587,64 @@ packages: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-gapcursor@3.19.0': + resolution: {integrity: sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==} + peerDependencies: + '@tiptap/extensions': ^3.19.0 + '@tiptap/extension-hard-break@3.19.0': resolution: {integrity: sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-heading@3.19.0': + resolution: {integrity: sha512-uLpLlfyp086WYNOc0ekm1gIZNlEDfmzOhKzB0Hbyi6jDagTS+p9mxUNYeYOn9jPUxpFov43+Wm/4E24oY6B+TQ==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/extension-history@3.19.0': resolution: {integrity: sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==} peerDependencies: '@tiptap/extensions': ^3.19.0 + '@tiptap/extension-horizontal-rule@3.19.0': + resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-image@3.19.0': + resolution: {integrity: sha512-/rGl8nBziBPVJJ/9639eQWFDKcI3RQsDM3s+cqYQMFQfMqc7sQB9h4o4sHCBpmKxk3Y0FV/0NjnjLbBVm8OKdQ==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-italic@3.19.0': + resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-link@3.19.0': + resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-list-item@3.19.0': + resolution: {integrity: sha512-VsSKuJz4/Tb6ZmFkXqWpDYkRzmaLTyE6dNSEpNmUpmZ32sMqo58mt11/huADNwfBFB0Ve7siH/VnFNIJYY3xvg==} + peerDependencies: + '@tiptap/extension-list': ^3.19.0 + + '@tiptap/extension-list-keymap@3.19.0': + resolution: {integrity: sha512-bxgmAgA3RzBGA0GyTwS2CC1c+QjkJJq9hC+S6PSOWELGRiTbwDN3MANksFXLjntkTa0N5fOnL27vBHtMStURqw==} + peerDependencies: + '@tiptap/extension-list': ^3.19.0 + + '@tiptap/extension-list@3.19.0': + resolution: {integrity: sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tiptap/extension-mention@3.19.0': resolution: {integrity: sha512-iBWX6mUouvDe9F75C2fJnFzvBFYVF8fcOa7UvzqWHRSCt8WxqSIp6C1B9Y0npP4TbIZySHzPV4NQQJhtmWwKww==} peerDependencies: @@ -2686,6 +2652,11 @@ packages: '@tiptap/pm': ^3.19.0 '@tiptap/suggestion': ^3.19.0 + '@tiptap/extension-ordered-list@3.19.0': + resolution: {integrity: sha512-cxGsINquwHYE1kmhAcLNLHAofmoDEG6jbesR5ybl7tU5JwtKVO7S/xZatll2DU1dsDAXWPWEeeMl4e/9svYjCg==} + peerDependencies: + '@tiptap/extension-list': ^3.19.0 + '@tiptap/extension-paragraph@3.19.0': resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==} peerDependencies: @@ -2696,11 +2667,52 @@ packages: peerDependencies: '@tiptap/extensions': ^3.19.0 + '@tiptap/extension-strike@3.19.0': + resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-table-cell@3.19.0': + resolution: {integrity: sha512-T67EDWmiRdOGctolaUpMPXffDkEFL+NUppSV61cPU3jQtDwGg01meauy5u67u6OUM8ICiZ+Sc7Xd2DIt+7Nv5g==} + peerDependencies: + '@tiptap/extension-table': ^3.19.0 + + '@tiptap/extension-table-header@3.19.0': + resolution: {integrity: sha512-fVHJUCfZp/JOE96hJ3paElRynpIVdukOiAjgZbaY9WuCKoXd3xYqdsbUSdf+XFmdPqwvQKhnkaY/qXW3FIxO3w==} + peerDependencies: + '@tiptap/extension-table': ^3.19.0 + + '@tiptap/extension-table-row@3.19.0': + resolution: {integrity: sha512-L3DeIXQARCs+m4rsQWiUPJso1z6xZ6awa/Kc+PCoq7rbPLtwhWUIld3sm9gdLac61Mf/TVwb18EdfygHcU7jCA==} + peerDependencies: + '@tiptap/extension-table': ^3.19.0 + + '@tiptap/extension-table@3.19.0': + resolution: {integrity: sha512-Lg8DlkkDUMYE/CcGOxoCWF98B2i7VWh+AGgqlF+XWrHjhlKHfENLRXm1a0vWuyyP3NknRYILoaaZ1s7QzmXKRA==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-task-item@3.19.0': + resolution: {integrity: sha512-1il70SoaoEA5jKr2QS30CpZoB7EzdSLugROMBPRUPc0feIBKAf6yUPhxlFyU4ez/uT4Pazsf1HAgHI3AOr+MtQ==} + peerDependencies: + '@tiptap/extension-list': ^3.19.0 + + '@tiptap/extension-task-list@3.19.0': + resolution: {integrity: sha512-Slb6YZi7XpVT966oAJhqzZu4LVuHtUeZgMxA0bdc8FSZBxntcr8OQWmPbqvR437RAR/Xd7b5quXS3JmSeksyvA==} + peerDependencies: + '@tiptap/extension-list': ^3.19.0 + '@tiptap/extension-text@3.19.0': resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-underline@3.19.0': + resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/extensions@3.19.0': resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==} peerDependencies: @@ -2720,6 +2732,9 @@ packages: react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tiptap/starter-kit@3.19.0': + resolution: {integrity: sha512-dTCkHEz+Y8ADxX7h+xvl6caAj+3nII/wMB1rTQchSuNKqJTOrzyUsCWm094+IoZmLT738wANE0fRIgziNHs/ug==} + '@tiptap/suggestion@3.19.0': resolution: {integrity: sha512-tUZwMRFqTVPIo566ZmHNRteyZxJy2EE4FA+S3IeIUOOvY6AW0h1imhbpBO7sXV8CeEQvpa+2DWwLvy7L3vmstA==} peerDependencies: @@ -2853,17 +2868,14 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/katex@0.16.8': - resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/linkify-it@3.0.5': + resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - '@types/lodash-es@4.17.12': - resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - - '@types/lodash@4.17.23': - resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/markdown-it@13.0.9': + resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -2871,6 +2883,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@1.0.5': + resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} + '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} @@ -3119,35 +3134,6 @@ packages: '@vitest/utils@1.6.1': resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} - '@vue/compiler-core@3.5.27': - resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==} - - '@vue/compiler-dom@3.5.27': - resolution: {integrity: sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==} - - '@vue/compiler-sfc@3.5.27': - resolution: {integrity: sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==} - - '@vue/compiler-ssr@3.5.27': - resolution: {integrity: sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==} - - '@vue/reactivity@3.5.27': - resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} - - '@vue/runtime-core@3.5.27': - resolution: {integrity: sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==} - - '@vue/runtime-dom@3.5.27': - resolution: {integrity: sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==} - - '@vue/server-renderer@3.5.27': - resolution: {integrity: sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==} - peerDependencies: - vue: 3.5.27 - - '@vue/shared@3.5.27': - resolution: {integrity: sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==} - '@xterm/addon-attach@0.12.0': resolution: {integrity: sha512-1lxvXM4JYSm60lbFmE8WMOy2oF2ip3Ye8jWorSAmwy7x8FiC53netEJ5RguL8+FSRj79MUQsNCb2hprY2QA2ig==} @@ -3815,6 +3801,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.3.1: resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} @@ -3870,10 +3859,6 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4058,9 +4043,6 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -4808,6 +4790,9 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + local-pkg@0.5.1: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} @@ -4854,6 +4839,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it-task-lists@2.1.1: + resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -4861,6 +4849,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -4870,9 +4863,6 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdast-util-definitions@6.0.0: - resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} - mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -4897,9 +4887,6 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - mdast-util-math@3.0.0: - resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} - mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -4969,9 +4956,6 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - micromark-extension-math@3.1.0: - resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} - micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -5073,6 +5057,9 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5095,11 +5082,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.6: - resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} - engines: {node: ^18 || >=20} - hasBin: true - napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -5391,9 +5373,6 @@ packages: prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} - prosemirror-drop-indicator@0.1.3: - resolution: {integrity: sha512-fJV6G2tHIVXZLUuc60fS9ly1/GuGOlAZUm67S1El+kGFUYh27Hyv6hcGx3rrJ+Q/JZL5jnyAibIZYYWpPqE45g==} - prosemirror-dropcursor@1.8.2: resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} @@ -5418,9 +5397,6 @@ packages: prosemirror-model@1.25.4: resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} - prosemirror-safari-ime-span@1.0.2: - resolution: {integrity: sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==} - prosemirror-schema-basic@1.2.4: resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} @@ -5446,20 +5422,6 @@ packages: prosemirror-view@1.41.5: resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==} - prosemirror-virtual-cursor@0.4.2: - resolution: {integrity: sha512-pUMKnIuOhhnMcgIJUjhIQTVJruBEGxfMBVQSrK0g2qhGPDm1i12KdsVaFw15dYk+29tZcxjMeR7P5VDKwmbwJg==} - peerDependencies: - prosemirror-model: ^1.0.0 - prosemirror-state: ^1.0.0 - prosemirror-view: ^1.0.0 - peerDependenciesMeta: - prosemirror-model: - optional: true - prosemirror-state: - optional: true - prosemirror-view: - optional: true - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5612,12 +5574,6 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} - remark-inline-links@7.0.0: - resolution: {integrity: sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==} - - remark-math@6.0.0: - resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} - remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -5627,9 +5583,6 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - remark@15.0.1: - resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5817,6 +5770,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5979,6 +5935,11 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + tiptap-markdown@0.9.0: + resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==} + peerDependencies: + '@tiptap/core': ^3.0.1 + tldts-core@7.0.19: resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} @@ -6109,9 +6070,6 @@ packages: unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - unist-util-remove-position@5.0.0: - resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} - unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -6280,14 +6238,6 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - vue@3.5.27: - resolution: {integrity: sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -6692,15 +6642,6 @@ snapshots: '@codemirror/view': 6.39.10 '@lezer/common': 1.5.0 - '@codemirror/lang-angular@0.1.4': - dependencies: - '@codemirror/lang-html': 6.4.11 - '@codemirror/lang-javascript': 6.2.4 - '@codemirror/language': 6.12.1 - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - '@codemirror/lang-cpp@6.0.3': dependencies: '@codemirror/language': 6.12.1 @@ -6749,38 +6690,11 @@ snapshots: '@lezer/common': 1.5.0 '@lezer/javascript': 1.5.4 - '@codemirror/lang-jinja@6.0.0': - dependencies: - '@codemirror/lang-html': 6.4.11 - '@codemirror/language': 6.12.1 - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - '@codemirror/lang-json@6.0.2': dependencies: '@codemirror/language': 6.12.1 '@lezer/json': 1.0.3 - '@codemirror/lang-less@6.0.2': - dependencies: - '@codemirror/lang-css': 6.3.1 - '@codemirror/language': 6.12.1 - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - - '@codemirror/lang-liquid@6.3.1': - dependencies: - '@codemirror/autocomplete': 6.20.0 - '@codemirror/lang-html': 6.4.11 - '@codemirror/language': 6.12.1 - '@codemirror/state': 6.5.3 - '@codemirror/view': 6.39.10 - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - '@codemirror/lang-markdown@6.5.0': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -6791,14 +6705,6 @@ snapshots: '@lezer/common': 1.5.0 '@lezer/markdown': 1.6.3 - '@codemirror/lang-php@6.0.2': - dependencies: - '@codemirror/lang-html': 6.4.11 - '@codemirror/language': 6.12.1 - '@codemirror/state': 6.5.3 - '@lezer/common': 1.5.0 - '@lezer/php': 1.0.5 - '@codemirror/lang-python@6.2.1': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -6812,48 +6718,6 @@ snapshots: '@codemirror/language': 6.12.1 '@lezer/rust': 1.0.2 - '@codemirror/lang-sass@6.0.2': - dependencies: - '@codemirror/lang-css': 6.3.1 - '@codemirror/language': 6.12.1 - '@codemirror/state': 6.5.3 - '@lezer/common': 1.5.0 - '@lezer/sass': 1.1.0 - - '@codemirror/lang-sql@6.10.0': - dependencies: - '@codemirror/autocomplete': 6.20.0 - '@codemirror/language': 6.12.1 - '@codemirror/state': 6.5.3 - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - - '@codemirror/lang-vue@0.1.3': - dependencies: - '@codemirror/lang-html': 6.4.11 - '@codemirror/lang-javascript': 6.2.4 - '@codemirror/language': 6.12.1 - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - - '@codemirror/lang-wast@6.0.2': - dependencies: - '@codemirror/language': 6.12.1 - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - - '@codemirror/lang-xml@6.1.0': - dependencies: - '@codemirror/autocomplete': 6.20.0 - '@codemirror/language': 6.12.1 - '@codemirror/state': 6.5.3 - '@codemirror/view': 6.39.10 - '@lezer/common': 1.5.0 - '@lezer/xml': 1.0.6 - '@codemirror/lang-yaml@6.1.2': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -6864,32 +6728,6 @@ snapshots: '@lezer/lr': 1.4.7 '@lezer/yaml': 1.0.3 - '@codemirror/language-data@6.5.2': - dependencies: - '@codemirror/lang-angular': 0.1.4 - '@codemirror/lang-cpp': 6.0.3 - '@codemirror/lang-css': 6.3.1 - '@codemirror/lang-go': 6.0.1 - '@codemirror/lang-html': 6.4.11 - '@codemirror/lang-java': 6.0.2 - '@codemirror/lang-javascript': 6.2.4 - '@codemirror/lang-jinja': 6.0.0 - '@codemirror/lang-json': 6.0.2 - '@codemirror/lang-less': 6.0.2 - '@codemirror/lang-liquid': 6.3.1 - '@codemirror/lang-markdown': 6.5.0 - '@codemirror/lang-php': 6.0.2 - '@codemirror/lang-python': 6.2.1 - '@codemirror/lang-rust': 6.0.2 - '@codemirror/lang-sass': 6.0.2 - '@codemirror/lang-sql': 6.10.0 - '@codemirror/lang-vue': 0.1.3 - '@codemirror/lang-wast': 6.0.2 - '@codemirror/lang-xml': 6.1.0 - '@codemirror/lang-yaml': 6.1.2 - '@codemirror/language': 6.12.1 - '@codemirror/legacy-modes': 6.5.2 - '@codemirror/language@6.12.1': dependencies: '@codemirror/state': 6.5.3 @@ -6899,10 +6737,6 @@ snapshots: '@lezer/lr': 1.4.7 style-mod: 4.1.3 - '@codemirror/legacy-modes@6.5.2': - dependencies: - '@codemirror/language': 6.12.1 - '@codemirror/lint@6.9.2': dependencies: '@codemirror/state': 6.5.3 @@ -7436,12 +7270,6 @@ snapshots: '@lezer/common': 1.5.0 '@lezer/highlight': 1.2.3 - '@lezer/php@1.0.5': - dependencies: - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - '@lezer/python@1.1.18': dependencies: '@lezer/common': 1.5.0 @@ -7454,18 +7282,6 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.7 - '@lezer/sass@1.1.0': - dependencies: - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - - '@lezer/xml@1.0.6': - dependencies: - '@lezer/common': 1.5.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.7 - '@lezer/yaml@1.0.3': dependencies: '@lezer/common': 1.5.0 @@ -7478,289 +7294,6 @@ snapshots: dependencies: langium: 3.3.1 - '@milkdown/components@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)': - dependencies: - '@codemirror/language': 6.12.1 - '@codemirror/state': 6.5.3 - '@codemirror/view': 6.39.10 - '@floating-ui/dom': 1.7.4 - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/plugin-tooltip': 7.18.0 - '@milkdown/preset-commonmark': 7.18.0 - '@milkdown/preset-gfm': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 - '@types/lodash-es': 4.17.12 - clsx: 2.1.1 - dompurify: 3.3.1 - lodash-es: 4.17.23 - nanoid: 5.1.6 - unist-util-visit: 5.0.0 - vue: 3.5.27(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - typescript - - '@milkdown/core@7.18.0': - dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - '@milkdown/crepe@7.18.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(typescript@5.9.3)': - dependencies: - '@codemirror/commands': 6.10.1 - '@codemirror/language': 6.12.1 - '@codemirror/language-data': 6.5.2 - '@codemirror/state': 6.5.3 - '@codemirror/theme-one-dark': 6.1.3 - '@codemirror/view': 6.39.10 - '@milkdown/kit': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3) - '@types/lodash-es': 4.17.12 - clsx: 2.1.1 - codemirror: 6.0.2 - katex: 0.16.28 - lodash-es: 4.17.23 - prosemirror-virtual-cursor: 0.4.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5) - remark-math: 6.0.0 - unist-util-visit: 5.0.0 - vue: 3.5.27(typescript@5.9.3) - transitivePeerDependencies: - - prosemirror-model - - prosemirror-state - - prosemirror-view - - supports-color - - typescript - - '@milkdown/ctx@7.18.0': - dependencies: - '@milkdown/exception': 7.18.0 - - '@milkdown/exception@7.18.0': {} - - '@milkdown/kit@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)': - dependencies: - '@milkdown/components': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3) - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/plugin-block': 7.18.0 - '@milkdown/plugin-clipboard': 7.18.0 - '@milkdown/plugin-cursor': 7.18.0 - '@milkdown/plugin-history': 7.18.0 - '@milkdown/plugin-indent': 7.18.0 - '@milkdown/plugin-listener': 7.18.0 - '@milkdown/plugin-slash': 7.18.0 - '@milkdown/plugin-tooltip': 7.18.0 - '@milkdown/plugin-trailing': 7.18.0 - '@milkdown/plugin-upload': 7.18.0 - '@milkdown/preset-commonmark': 7.18.0 - '@milkdown/preset-gfm': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 - transitivePeerDependencies: - - '@codemirror/language' - - '@codemirror/state' - - '@codemirror/view' - - supports-color - - typescript - - '@milkdown/plugin-block@7.18.0': - dependencies: - '@floating-ui/dom': 1.7.4 - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - '@types/lodash-es': 4.17.12 - lodash-es: 4.17.23 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-clipboard@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-cursor@7.18.0': - dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - prosemirror-drop-indicator: 0.1.3 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-history@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-indent@7.18.0': - dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-listener@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@types/lodash-es': 4.17.12 - lodash-es: 4.17.23 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-slash@7.18.0': - dependencies: - '@floating-ui/dom': 1.7.4 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - '@types/lodash-es': 4.17.12 - lodash-es: 4.17.23 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-tooltip@7.18.0': - dependencies: - '@floating-ui/dom': 1.7.4 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - '@types/lodash-es': 4.17.12 - lodash-es: 4.17.23 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-trailing@7.18.0': - dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - transitivePeerDependencies: - - supports-color - - '@milkdown/plugin-upload@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 - transitivePeerDependencies: - - supports-color - - '@milkdown/preset-commonmark@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 - remark-inline-links: 7.0.0 - unist-util-visit: 5.0.0 - unist-util-visit-parents: 6.0.2 - transitivePeerDependencies: - - supports-color - - '@milkdown/preset-gfm@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/preset-commonmark': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 - prosemirror-safari-ime-span: 1.0.2 - remark-gfm: 4.0.1 - transitivePeerDependencies: - - supports-color - - '@milkdown/prose@7.18.0': - dependencies: - '@milkdown/exception': 7.18.0 - prosemirror-changeset: 2.3.1 - prosemirror-commands: 1.7.1 - prosemirror-dropcursor: 1.8.2 - prosemirror-gapcursor: 1.4.0 - prosemirror-history: 1.5.0 - prosemirror-inputrules: 1.5.1 - prosemirror-keymap: 1.2.3 - prosemirror-model: 1.25.4 - prosemirror-schema-list: 1.5.1 - prosemirror-state: 1.4.4 - prosemirror-tables: 1.8.5 - prosemirror-transform: 1.11.0 - prosemirror-view: 1.41.5 - - '@milkdown/react@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': - dependencies: - '@milkdown/crepe': 7.18.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(typescript@5.9.3) - '@milkdown/kit': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - transitivePeerDependencies: - - '@codemirror/language' - - '@codemirror/state' - - '@codemirror/view' - - prosemirror-model - - prosemirror-state - - prosemirror-view - - supports-color - - typescript - - '@milkdown/theme-nord@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - clsx: 2.1.1 - transitivePeerDependencies: - - supports-color - - '@milkdown/transformer@7.18.0': - dependencies: - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - remark: 15.0.1 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - '@milkdown/utils@7.18.0': - dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - nanoid: 5.1.6 - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.8(hono@4.11.3) @@ -7783,6 +7316,17 @@ snapshots: - hono - supports-color + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@mswjs/interceptors@0.40.0': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -7851,8 +7395,6 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@ocavue/utils@1.3.1': {} - '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -8878,12 +8420,23 @@ snapshots: dependencies: '@tiptap/pm': 3.19.0 + '@tiptap/extension-blockquote@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@floating-ui/dom': 1.7.4 '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 - optional: true + + '@tiptap/extension-bullet-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@tiptap/extension-code-block-lowlight@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(highlight.js@11.11.1)(lowlight@3.3.0)': dependencies: @@ -8906,6 +8459,10 @@ snapshots: dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@floating-ui/dom': 1.7.4 @@ -8913,20 +8470,64 @@ snapshots: '@tiptap/pm': 3.19.0 optional: true + '@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-heading@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-history@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/extension-image@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/extension-mention@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 '@tiptap/suggestion': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) @@ -8935,10 +8536,43 @@ snapshots: dependencies: '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-table-cell@3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-table': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-table-header@3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-table': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-table-row@3.19.0(@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-table': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-table@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/extension-task-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-task-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) @@ -8982,6 +8616,33 @@ snapshots: transitivePeerDependencies: - '@floating-ui/dom' + '@tiptap/starter-kit@3.19.0': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-blockquote': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-bold': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-bullet-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-document': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-heading': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) @@ -9143,15 +8804,14 @@ snapshots: '@types/json5@0.0.29': {} - '@types/katex@0.16.8': {} + '@types/linkify-it@3.0.5': {} '@types/linkify-it@5.0.0': {} - '@types/lodash-es@4.17.12': + '@types/markdown-it@13.0.9': dependencies: - '@types/lodash': 4.17.23 - - '@types/lodash@4.17.23': {} + '@types/linkify-it': 3.0.5 + '@types/mdurl': 1.0.5 '@types/markdown-it@14.1.2': dependencies: @@ -9162,6 +8822,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mdurl@1.0.5': {} + '@types/mdurl@2.0.0': {} '@types/ms@2.1.0': {} @@ -9427,60 +9089,6 @@ snapshots: loupe: 2.3.7 pretty-format: 29.7.0 - '@vue/compiler-core@3.5.27': - dependencies: - '@babel/parser': 7.28.6 - '@vue/shared': 3.5.27 - entities: 7.0.1 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - - '@vue/compiler-dom@3.5.27': - dependencies: - '@vue/compiler-core': 3.5.27 - '@vue/shared': 3.5.27 - - '@vue/compiler-sfc@3.5.27': - dependencies: - '@babel/parser': 7.28.6 - '@vue/compiler-core': 3.5.27 - '@vue/compiler-dom': 3.5.27 - '@vue/compiler-ssr': 3.5.27 - '@vue/shared': 3.5.27 - estree-walker: 2.0.2 - magic-string: 0.30.21 - postcss: 8.5.6 - source-map-js: 1.2.1 - - '@vue/compiler-ssr@3.5.27': - dependencies: - '@vue/compiler-dom': 3.5.27 - '@vue/shared': 3.5.27 - - '@vue/reactivity@3.5.27': - dependencies: - '@vue/shared': 3.5.27 - - '@vue/runtime-core@3.5.27': - dependencies: - '@vue/reactivity': 3.5.27 - '@vue/shared': 3.5.27 - - '@vue/runtime-dom@3.5.27': - dependencies: - '@vue/reactivity': 3.5.27 - '@vue/runtime-core': 3.5.27 - '@vue/shared': 3.5.27 - csstype: 3.2.3 - - '@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3))': - dependencies: - '@vue/compiler-ssr': 3.5.27 - '@vue/shared': 3.5.27 - vue: 3.5.27(typescript@5.9.3) - - '@vue/shared@3.5.27': {} - '@xterm/addon-attach@0.12.0': {} '@xterm/addon-fit@0.11.0': {} @@ -10163,6 +9771,10 @@ snapshots: '@babel/runtime': 7.28.6 csstype: 3.2.3 + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dompurify@3.3.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -10213,8 +9825,6 @@ snapshots: entities@4.5.0: {} - entities@7.0.1: {} - env-paths@2.2.1: {} error-ex@1.3.4: @@ -10590,8 +10200,6 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} - estree-walker@2.0.2: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -11337,6 +10945,8 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkifyjs@4.3.2: {} + local-pkg@0.5.1: dependencies: mlly: 1.8.0 @@ -11385,6 +10995,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it-task-lists@2.1.1: {} + markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -11396,16 +11008,12 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + marked@16.4.2: {} math-intrinsics@1.1.0: {} - mdast-util-definitions@6.0.0: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - unist-util-visit: 5.0.0 - mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -11487,18 +11095,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-math@3.0.0: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - longest-streak: 3.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - unist-util-remove-position: 5.0.0 - transitivePeerDependencies: - - supports-color - mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -11686,16 +11282,6 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-math@3.1.0: - dependencies: - '@types/katex': 0.16.8 - devlop: 1.1.0 - katex: 0.16.28 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -11848,6 +11434,11 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.2 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3): @@ -11879,8 +11470,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@5.1.6: {} - napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -12193,13 +11782,6 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - prosemirror-drop-indicator@0.1.3: - dependencies: - '@ocavue/utils': 1.3.1 - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.5 - prosemirror-dropcursor@1.8.2: dependencies: prosemirror-state: 1.4.4 @@ -12247,11 +11829,6 @@ snapshots: dependencies: orderedmap: 2.1.1 - prosemirror-safari-ime-span@1.0.2: - dependencies: - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.5 - prosemirror-schema-basic@1.2.4: dependencies: prosemirror-model: 1.25.4 @@ -12294,12 +11871,6 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - prosemirror-virtual-cursor@0.4.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5): - optionalDependencies: - prosemirror-model: 1.25.4 - prosemirror-state: 1.4.4 - prosemirror-view: 1.41.5 - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -12544,21 +12115,6 @@ snapshots: transitivePeerDependencies: - supports-color - remark-inline-links@7.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-definitions: 6.0.0 - unist-util-visit: 5.0.0 - - remark-math@6.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-math: 3.0.0 - micromark-extension-math: 3.1.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -12582,15 +12138,6 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remark@15.0.1: - dependencies: - '@types/mdast': 4.0.4 - remark-parse: 11.0.0 - remark-stringify: 11.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -12902,6 +12449,8 @@ snapshots: stackback@0.0.2: {} + state-local@1.0.7: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -13060,6 +12609,14 @@ snapshots: tinyspy@2.2.1: {} + tiptap-markdown@0.9.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)): + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@types/markdown-it': 13.0.9 + markdown-it: 14.1.1 + markdown-it-task-lists: 2.1.1 + prosemirror-markdown: 1.13.4 + tldts-core@7.0.19: {} tldts@7.0.19: @@ -13212,11 +12769,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - unist-util-remove-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-visit: 5.0.0 - unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -13415,16 +12967,6 @@ snapshots: vscode-uri@3.0.8: {} - vue@3.5.27(typescript@5.9.3): - dependencies: - '@vue/compiler-dom': 3.5.27 - '@vue/compiler-sfc': 3.5.27 - '@vue/runtime-dom': 3.5.27 - '@vue/server-renderer': 3.5.27(vue@3.5.27(typescript@5.9.3)) - '@vue/shared': 3.5.27 - optionalDependencies: - typescript: 5.9.3 - w3c-keyname@2.2.8: {} web-streams-polyfill@3.3.3: {} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 25e1c5aa6b..83f51ac4ea 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -515,229 +515,7 @@ background: color-mix(in oklab, var(--primary) 30%, var(--background)); } -/* Milkdown Editor Styles */ -.milkdown-wrapper { - height: 100%; - overflow: auto; - border-radius: 0; -} - -.milkdown-wrapper:not(.dark) { - background: #ffffff; -} - -.milkdown-wrapper .milkdown { - height: 100%; - background: transparent; - display: flex; - flex-direction: column; -} - -.milkdown-wrapper .milkdown [data-milkdown-root] { - flex: 1; - display: flex; - flex-direction: column; -} - -.milkdown-wrapper .editor { - padding: 0.75rem; - flex: 1; - min-height: 100%; - outline: none; - background: transparent; - cursor: text; -} - -.milkdown-wrapper .editor:focus { - outline: none; -} - -/* Override nord theme colors to match app theme */ -.milkdown-wrapper .milkdown { - --nord-background: transparent; - --nord-foreground: var(--foreground); - --nord-outline: var(--border); - --nord-primary: var(--primary); - --nord-secondary: var(--muted); - --nord-neutral: var(--muted-foreground); - --nord-solid: var(--card); - --nord-line: var(--border); - --nord-surface: var(--muted); - --nord-shadow: var(--border); -} - -/* Base text styling - match chat text-xs */ -.milkdown-wrapper .editor, -.milkdown-wrapper .editor *:not(.mermaid-container):not(.mermaid-container *) { - font-size: 0.75rem !important; - line-height: 1.625; - color: var(--foreground); -} - -/* Typography - headings */ -.milkdown-wrapper .editor h1, -.milkdown-wrapper .editor h2, -.milkdown-wrapper .editor h3, -.milkdown-wrapper .editor h4, -.milkdown-wrapper .editor h5, -.milkdown-wrapper .editor h6 { - font-weight: 700 !important; - color: var(--foreground) !important; - margin-top: 0.5rem !important; - margin-bottom: 0.25rem !important; - line-height: 1.3 !important; -} - -.milkdown-wrapper .editor h1 { font-size: 0.875rem !important; } -.milkdown-wrapper .editor h2 { font-size: 0.8125rem !important; } -.milkdown-wrapper .editor h3 { font-size: 0.75rem !important; } - -/* Paragraphs */ -.milkdown-wrapper .editor p { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - line-height: 1.625 !important; - font-size: 0.75rem !important; -} - -/* Lists */ -.milkdown-wrapper .editor ul { - list-style-type: disc !important; - padding-left: 1.25rem !important; - margin-top: 0.375rem !important; - margin-bottom: 0.375rem !important; - font-size: 0.75rem !important; -} - -.milkdown-wrapper .editor ol { - list-style-type: decimal !important; - padding-left: 1.25rem !important; - margin-top: 0.375rem !important; - margin-bottom: 0.375rem !important; - font-size: 0.75rem !important; -} - -.milkdown-wrapper .editor li { - margin-top: 0.125rem !important; - margin-bottom: 0.125rem !important; - font-size: 0.75rem !important; - line-height: 1.5 !important; -} - -/* Inline code - matching chat InlineCode component */ -.milkdown-wrapper .editor code { - background: rgb(59 130 246 / 0.2) !important; - color: rgb(96 165 250) !important; - padding: 0.125rem 0.375rem !important; - border-radius: 0.25rem !important; - font-size: 0.675rem !important; - font-family: var(--font-mono) !important; -} - -/* Code blocks */ -.milkdown-wrapper .editor pre { - background: var(--background) !important; - padding: 0.5rem 0.75rem !important; - border-radius: 0.375rem !important; - overflow-x: auto !important; - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; -} - -.milkdown-wrapper:not(.dark) .editor pre { - background: #f5f5f5 !important; -} - -.milkdown-wrapper .editor pre code { - background: transparent !important; - color: #d4d4d4 !important; - padding: 0 !important; - font-size: 0.625rem !important; - font-family: var(--font-mono) !important; -} - -.milkdown-wrapper:not(.dark) .editor pre code { - color: #1e1e1e !important; -} - -/* Blockquote */ -.milkdown-wrapper .editor blockquote { - border-left: 3px solid var(--primary) !important; - padding-left: 0.75rem !important; - margin-left: 0 !important; - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; - color: var(--muted-foreground) !important; - font-style: italic !important; -} - -/* Links */ -.milkdown-wrapper .editor a { - color: var(--primary) !important; - text-decoration: underline !important; - text-underline-offset: 2px !important; -} - -/* Strong/Bold */ -.milkdown-wrapper .editor strong { - font-weight: 700; - color: var(--foreground); -} - -/* Emphasis */ -.milkdown-wrapper .editor em { - font-style: italic; -} - -/* Horizontal rule */ -.milkdown-wrapper .editor hr { - border: none !important; - border-top: 1px solid var(--border) !important; - margin: 1rem 0 !important; -} - -/* Comment highlights in plan editor - inline */ -.milkdown-wrapper .comment-highlight { - background-color: rgba(59, 130, 246, 0.45); - border-radius: 3px; - cursor: pointer; - transition: background-color 0.15s ease; - padding: 2px 1px; - box-decoration-break: clone; - -webkit-box-decoration-break: clone; -} - -.milkdown-wrapper .comment-highlight:hover { - background-color: rgba(59, 130, 246, 0.6); -} - -/* Mermaid diagram styles */ -.milkdown-wrapper .mermaid-container { - margin: 0.75rem 0 !important; - padding: 0.5rem !important; - overflow-x: auto !important; -} - -.milkdown-wrapper .mermaid-container svg { - max-width: 100% !important; - height: auto !important; -} - -.milkdown-wrapper .mermaid-error { - color: #ef4444 !important; - font-size: 0.75rem !important; - padding: 0.5rem !important; - white-space: pre-wrap !important; -} - -/* Placeholder for empty editor - shows at cursor position like a real editor */ -.milkdown-wrapper .editor-placeholder { - color: var(--muted-foreground); - opacity: 0.5; - pointer-events: none; - font-size: 0.75rem; - user-select: none; -} +/* Legacy Milkdown styles removed - plan editor now uses TipTap */ /* TipTap editor styles */ .tiptap p.is-editor-empty:first-child::before { @@ -870,6 +648,343 @@ font-weight: bold; } +/* Monaco Editor - Comment decorations */ +.monaco-comment-line { + background-color: rgba(99, 102, 241, 0.15) !important; + border-left: 3px solid rgba(99, 102, 241, 0.6) !important; +} + +.monaco-comment-glyph { + background-color: rgba(99, 102, 241, 0.2); + cursor: pointer; +} + +.monaco-comment-glyph::after { + content: ''; + display: block; + width: 12px; + height: 12px; + margin: auto; + background: rgba(99, 102, 241, 0.9); + mask-image: url("data:image/svg+xml;utf8,"); + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-size: contain; + -webkit-mask-size: contain; +} + +.monaco-comment-glyph:hover { + background-color: rgba(99, 102, 241, 0.35); +} + +.monaco-comment-glyph-bg { + background-color: rgba(99, 102, 241, 0.2); +} + +/* TipTap Plan Editor Styles */ +.tiptap-plan-wrapper { + height: 100%; + overflow: auto; + border-radius: 0; +} + +.tiptap-plan-wrapper:not(.dark) { + background: #ffffff; +} + +.tiptap-plan-wrapper .tiptap { + height: 100%; + padding: 0.75rem; + outline: none; + cursor: text; +} + +.tiptap-plan-wrapper .tiptap:focus { + outline: none; +} + +/* Base text styling */ +.tiptap-plan-wrapper .tiptap, +.tiptap-plan-wrapper .tiptap *:not(.mermaid-container):not(.mermaid-container *) { + font-size: 0.75rem !important; + line-height: 1.625; + color: var(--foreground); +} + +/* Headings */ +.tiptap-plan-wrapper .tiptap h1, +.tiptap-plan-wrapper .tiptap h2, +.tiptap-plan-wrapper .tiptap h3, +.tiptap-plan-wrapper .tiptap h4 { + font-weight: 700 !important; + color: var(--foreground) !important; + margin-top: 0.5rem !important; + margin-bottom: 0.25rem !important; + line-height: 1.3 !important; +} + +.tiptap-plan-wrapper .tiptap h1 { font-size: 0.875rem !important; } +.tiptap-plan-wrapper .tiptap h2 { font-size: 0.8125rem !important; } +.tiptap-plan-wrapper .tiptap h3 { font-size: 0.75rem !important; } + +/* Paragraphs */ +.tiptap-plan-wrapper .tiptap p { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + line-height: 1.625 !important; + font-size: 0.75rem !important; +} + +/* Lists */ +.tiptap-plan-wrapper .tiptap ul { + list-style-type: disc !important; + padding-left: 1.25rem !important; + margin-top: 0.375rem !important; + margin-bottom: 0.375rem !important; +} + +.tiptap-plan-wrapper .tiptap ol { + list-style-type: decimal !important; + padding-left: 1.25rem !important; + margin-top: 0.375rem !important; + margin-bottom: 0.375rem !important; +} + +.tiptap-plan-wrapper .tiptap li { + margin-top: 0.125rem !important; + margin-bottom: 0.125rem !important; +} + +/* Task lists */ +.tiptap-plan-wrapper .tiptap ul[data-type="taskList"] { + list-style: none !important; + padding-left: 0 !important; +} + +.tiptap-plan-wrapper .tiptap ul[data-type="taskList"] li { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.tiptap-plan-wrapper .tiptap ul[data-type="taskList"] li label { + margin-top: 0.125rem; +} + +/* Inline code */ +.tiptap-plan-wrapper .tiptap code { + background: rgb(59 130 246 / 0.2) !important; + color: rgb(96 165 250) !important; + padding: 0.125rem 0.375rem !important; + border-radius: 0.25rem !important; + font-size: 0.675rem !important; + font-family: var(--font-mono) !important; +} + +/* Code blocks */ +.tiptap-plan-wrapper .tiptap pre { + background: var(--background) !important; + padding: 0.5rem 0.75rem !important; + border-radius: 0.375rem !important; + overflow-x: auto !important; + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} + +.tiptap-plan-wrapper:not(.dark) .tiptap pre { + background: #f5f5f5 !important; +} + +.tiptap-plan-wrapper .tiptap pre code { + background: transparent !important; + color: #d4d4d4 !important; + padding: 0 !important; + font-size: 0.625rem !important; + font-family: var(--font-mono) !important; +} + +.tiptap-plan-wrapper:not(.dark) .tiptap pre code { + color: #1e1e1e !important; +} + +/* Blockquote */ +.tiptap-plan-wrapper .tiptap blockquote { + border-left: 3px solid var(--primary) !important; + padding-left: 0.75rem !important; + margin-left: 0 !important; + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + color: var(--muted-foreground) !important; + font-style: italic !important; +} + +/* Links */ +.tiptap-plan-wrapper .tiptap a { + color: var(--primary) !important; + text-decoration: underline !important; + text-underline-offset: 2px !important; +} + +/* Horizontal rule */ +.tiptap-plan-wrapper .tiptap hr { + border: none !important; + border-top: 1px solid var(--border) !important; + margin: 1rem 0 !important; +} + +/* Tables */ +.tiptap-plan-wrapper .tiptap table { + border-collapse: collapse; + width: 100%; + margin: 0.5rem 0; +} + +.tiptap-plan-wrapper .tiptap th, +.tiptap-plan-wrapper .tiptap td { + border: 1px solid var(--border); + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.tiptap-plan-wrapper .tiptap th { + background: var(--muted); + font-weight: 600; +} + +/* Comment highlights */ +.tiptap-plan-wrapper .comment-highlight { + background-color: rgba(59, 130, 246, 0.45); + border-radius: 3px; + cursor: pointer; + transition: background-color 0.15s ease; + padding: 2px 1px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.tiptap-plan-wrapper .comment-highlight:hover { + background-color: rgba(59, 130, 246, 0.6); +} + +/* Mermaid diagram styles */ +.tiptap-plan-wrapper .mermaid-container { + margin: 0.75rem 0 !important; + padding: 0.5rem !important; + overflow-x: auto !important; +} + +.tiptap-plan-wrapper .mermaid-container svg { + max-width: 100% !important; + height: auto !important; +} + +.tiptap-plan-wrapper .mermaid-error { + color: #ef4444 !important; + font-size: 0.75rem !important; + padding: 0.5rem !important; + white-space: pre-wrap !important; +} + +/* Placeholder for empty plan editor */ +.tiptap-plan-wrapper .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--muted-foreground); + opacity: 0.5; + pointer-events: none; + height: 0; + font-size: 0.75rem; +} + +/* Lowlight syntax colors for TipTap plan code blocks */ +.tiptap-plan-wrapper .tiptap pre code .hljs-comment, +.tiptap-plan-wrapper .tiptap pre code .hljs-quote { + color: var(--muted-foreground); + font-style: italic; +} + +.tiptap-plan-wrapper .tiptap pre code .hljs-keyword, +.tiptap-plan-wrapper .tiptap pre code .hljs-selector-tag, +.tiptap-plan-wrapper .tiptap pre code .hljs-built_in { + color: oklch(0.65 0.2 300); +} + +.tiptap-plan-wrapper .tiptap pre code .hljs-string, +.tiptap-plan-wrapper .tiptap pre code .hljs-attr, +.tiptap-plan-wrapper .tiptap pre code .hljs-addition { + color: oklch(0.7 0.15 150); +} + +.tiptap-plan-wrapper .tiptap pre code .hljs-number, +.tiptap-plan-wrapper .tiptap pre code .hljs-literal { + color: oklch(0.7 0.15 60); +} + +.tiptap-plan-wrapper .tiptap pre code .hljs-title, +.tiptap-plan-wrapper .tiptap pre code .hljs-section, +.tiptap-plan-wrapper .tiptap pre code .hljs-type { + color: oklch(0.75 0.15 80); +} + +.tiptap-plan-wrapper .tiptap pre code .hljs-name, +.tiptap-plan-wrapper .tiptap pre code .hljs-tag { + color: oklch(0.65 0.2 20); +} + +/* Monaco Editor - Diff gutter indicators */ +.monaco-diff-added-gutter { + width: 3px !important; + margin-left: 3px; + background-color: #10b981 !important; +} +.monaco-diff-modified-gutter { + width: 3px !important; + margin-left: 3px; + background-color: #3b82f6 !important; +} +.monaco-diff-deleted-gutter { + width: 0 !important; + margin-left: 2px; + border-left: 4px solid #f43f5e !important; +} + +/* Monaco Editor - Peek view / references widget overrides */ +.monaco-editor .peekview-widget .head { + box-sizing: border-box; +} + +.monaco-editor .peekview-widget .head .peekview-title .filename { + font-size: 12px; + font-weight: 500; +} + +.monaco-editor .peekview-widget .head .peekview-title .dirname { + font-size: 11px; +} + +/* Match references list font size to editor font size (12px) */ +.monaco-editor .reference-zone-widget .ref-tree .monaco-list-row { + font-size: 12px !important; + height: 20px !important; + line-height: 20px !important; +} + +.monaco-editor .reference-zone-widget .ref-tree .reference-file { + font-size: 12px !important; + height: 22px !important; + line-height: 22px !important; +} + +.monaco-editor .reference-zone-widget .ref-tree .monaco-tl-contents { + padding-left: 4px; +} + +.monaco-editor .reference-zone-widget .ref-tree .reference-file .count { + font-size: 10px; + line-height: 14px; + padding: 0 5px; + border-radius: 8px; +} + .debug { border: 1px solid red !important; } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 3ede91c5db..2d5db20286 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -60,6 +60,9 @@ export default async function Page({ searchParams }: PageProps) { enablePreviewOnClick: settingsEnablePreviewOnClick, chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter', reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true, + lspAutoStartLanguages: userSettings?.lsp_auto_start_languages ?? [], + lspAutoInstallLanguages: userSettings?.lsp_auto_install_languages ?? [], + lspServerConfigs: userSettings?.lsp_server_configs ?? {}, loaded: Boolean(userSettings), }, }; diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index d979412404..d859dab2c4 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -229,6 +229,9 @@ export default async function SessionPage({ enablePreviewOnClick: userSettings?.enable_preview_on_click ?? false, chatSubmitKey: userSettings?.chat_submit_key ?? 'cmd_enter', reviewAutoMarkOnScroll: userSettings?.review_auto_mark_on_scroll ?? true, + lspAutoStartLanguages: userSettings?.lsp_auto_start_languages ?? [], + lspAutoInstallLanguages: userSettings?.lsp_auto_install_languages ?? [], + lspServerConfigs: userSettings?.lsp_server_configs ?? {}, loaded: Boolean(userSettings), }, }; diff --git a/apps/web/components/diff/diff-view-inline.tsx b/apps/web/components/diff/diff-view-inline.tsx index 4e9414708b..025ce20a14 100644 --- a/apps/web/components/diff/diff-view-inline.tsx +++ b/apps/web/components/diff/diff-view-inline.tsx @@ -1,4 +1,4 @@ 'use client'; -// Re-export from the unified diff-viewer component -export { DiffViewInline } from './diff-viewer'; +// Re-export resolver-aware version +export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver'; diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx new file mode 100644 index 0000000000..b981d5d987 --- /dev/null +++ b/apps/web/components/diff/diff-viewer-resolver.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { memo } from 'react'; +import { useEditorProvider } from '@/hooks/use-editor-resolver'; +import { DiffViewer as PierreDiffViewer, DiffViewInline as PierreDiffViewInline } from './diff-viewer'; +import { MonacoDiffViewer } from '@/components/editors/monaco/monaco-diff-viewer'; +import type { FileDiffData, DiffComment } from '@/lib/diff/types'; + +interface DiffViewerResolverProps { + data: FileDiffData; + enableComments?: boolean; + sessionId?: string; + onCommentAdd?: (comment: DiffComment) => void; + onCommentDelete?: (commentId: string) => void; + comments?: DiffComment[]; + className?: string; + compact?: boolean; + hideHeader?: boolean; + onOpenFile?: (filePath: string) => void; + onRevert?: (filePath: string) => void; + wordWrap?: boolean; +} + +export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) { + const provider = useEditorProvider('diff-viewer'); + if (provider === 'monaco') { + const { enableComments: _, ...rest } = props; + return ; + } + return ; +}); + +export function DiffViewInlineResolved({ data, className }: { data: FileDiffData; className?: string }) { + const provider = useEditorProvider('chat-diff'); + if (provider === 'monaco') { + return ; + } + return ; +} diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index 3c59fcad8e..ef42727998 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -8,7 +8,7 @@ import type { FileDiffOptions, FileDiffMetadata, DiffLineAnnotation, RenderHeade import { cn } from '@kandev/ui/lib/utils'; import { Button } from '@kandev/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip'; -import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconExternalLink, IconPlus, IconArrowBackUp } from '@tabler/icons-react'; +import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconPlus, IconArrowBackUp } from '@tabler/icons-react'; import type { FileDiffData, DiffComment } from '@/lib/diff/types'; import { useDiffComments } from './use-diff-comments'; import { CommentForm } from './comment-form'; @@ -407,10 +407,10 @@ export const DiffViewer = memo(function DiffViewer({ className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100" onClick={() => onOpenFile(filePath)} > - + - Open in editor + Edit )} diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx index 64cb95cae4..ee6b262354 100644 --- a/apps/web/components/diff/file-diff-viewer.tsx +++ b/apps/web/components/diff/file-diff-viewer.tsx @@ -1,7 +1,7 @@ 'use client'; import { memo, useMemo } from 'react'; -import { DiffViewer } from './diff-viewer'; +import { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver'; import { transformGitDiff } from '@/lib/diff'; import type { DiffComment } from '@/lib/diff/types'; diff --git a/apps/web/components/diff/index.ts b/apps/web/components/diff/index.ts index a0f46f911d..d31f473f95 100644 --- a/apps/web/components/diff/index.ts +++ b/apps/web/components/diff/index.ts @@ -1,9 +1,13 @@ -// Main components -export { DiffViewer } from './diff-viewer'; +// Main components - resolver-aware +export { DiffViewerResolved as DiffViewer } from './diff-viewer-resolver'; +export { DiffViewInlineResolved as DiffViewInline } from './diff-viewer-resolver'; export { FileDiffViewer } from './file-diff-viewer'; -export { DiffViewInline } from './diff-view-inline'; export { CommentForm } from './comment-form'; export { CommentDisplay } from './comment-display'; +// Direct access to Pierre implementation (for internal use) +export { DiffViewer as PierreDiffViewer } from './diff-viewer'; +export { DiffViewInline as PierreDiffViewInline } from './diff-viewer'; + // Hooks export { useDiffComments } from './use-diff-comments'; diff --git a/apps/web/components/editors/codemirror/codemirror-code-block.tsx b/apps/web/components/editors/codemirror/codemirror-code-block.tsx new file mode 100644 index 0000000000..56c94b9792 --- /dev/null +++ b/apps/web/components/editors/codemirror/codemirror-code-block.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import CodeMirror from '@uiw/react-codemirror'; +import { vscodeDark, vscodeLight } from '@uiw/codemirror-theme-vscode'; +import { EditorView } from '@codemirror/view'; +import { IconCheck, IconCopy } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import { getCodeMirrorExtension } from '@/lib/languages'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; + +type CodeBlockProps = { + children: React.ReactNode; + className?: string; +}; + +const getLanguageExtension = (language?: string) => { + if (!language) return undefined; + const lang = language.replace('language-', '').toLowerCase(); + return getCodeMirrorExtension(lang); +}; + +export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) { + const { copied, copy } = useCopyToClipboard(); + const { theme, systemTheme } = useTheme(); + const effectiveTheme = theme === 'system' ? systemTheme : theme; + + const code = String(children).replace(/\n$/, ''); + const languageExtension = getLanguageExtension(className); + + // Custom padding theme + const paddingTheme = EditorView.theme({ + '&': { + padding: '2px 2px', + }, + }); + + const handleCopy = async () => { + await copy(code); + }; + + return ( +
+ {/* Copy button */} + + + {/* Code editor */} + +
+ ); +} diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx new file mode 100644 index 0000000000..9b9b91ef9c --- /dev/null +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -0,0 +1,649 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror'; +import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view'; +import type { Extension } from '@codemirror/state'; +import { Decoration, type DecorationSet } from '@codemirror/view'; +import { getCodeMirrorExtensionFromPath } from '@/lib/languages'; +import { Button } from '@kandev/ui/button'; +import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { formatDiffStats } from '@/lib/utils/file-diff'; +import { toRelativePath } from '@/lib/utils'; +import { vscodeDark } from '@uiw/codemirror-theme-vscode'; +import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice'; +import type { DiffComment } from '@/lib/diff/types'; +import { useToast } from '@/components/toast-provider'; +import { EditorCommentPopover } from '@/components/task/editor-comment-popover'; +import { CommentViewPopover } from '@/components/task/comment-view-popover'; + +type FileEditorContentProps = { + path: string; + content: string; + originalContent: string; + isDirty: boolean; + isSaving: boolean; + sessionId?: string; + worktreePath?: string; + enableComments?: boolean; + onChange: (newContent: string) => void; + onSave: () => void; + onDelete?: () => void; +}; + +type TextSelection = { + text: string; + startLine: number; + endLine: number; + position: { x: number; y: number }; +} | null; + +type FloatingButtonPosition = { + x: number; + y: number; +} | null; + +type CommentViewState = { + comments: DiffComment[]; + position: { x: number; y: number }; +} | null; + +// Create comment line decorations +function createCommentDecorations( + view: EditorView, + comments: DiffComment[] +): DecorationSet { + if (comments.length === 0) { + return Decoration.none; + } + + const decorations: Array<{ from: number; decoration: Decoration }> = []; + + // Get all lines that have comments + const linesWithComments = new Set(); + for (const comment of comments) { + for (let line = comment.startLine; line <= comment.endLine; line++) { + linesWithComments.add(line); + } + } + + // Create line decorations for commented lines + for (const lineNum of linesWithComments) { + if (lineNum > view.state.doc.lines) continue; + const line = view.state.doc.line(lineNum); + decorations.push({ + from: line.from, + decoration: Decoration.line({ + class: 'cm-comment-line', + }), + }); + } + + decorations.sort((a, b) => a.from - b.from); + return Decoration.set(decorations.map(d => d.decoration.range(d.from))); +} + +// Gutter marker for lines with comments - defined outside component to avoid re-creation +class CommentGutterMarker extends GutterMarker { + constructor( + readonly lineComments: DiffComment[], + readonly isFirstLine: boolean + ) { + super(); + } + + toDOM() { + const marker = document.createElement('div'); + marker.className = 'cm-comment-gutter-marker'; + marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`; + + // Add icon only on the first line of a comment + if (this.isFirstLine) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '12'); + svg.setAttribute('height', '12'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)'); + svg.setAttribute('stroke-width', '2'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'); + svg.appendChild(path); + marker.appendChild(svg); + } + + return marker; + } + + eq(other: CommentGutterMarker) { + return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine; + } +} + +export function CodeMirrorCodeEditor({ + path, + content, + originalContent, + isDirty, + isSaving, + sessionId, + worktreePath, + enableComments = false, + onChange, + onSave, + onDelete, +}: FileEditorContentProps) { + const [wrapEnabled, setWrapEnabled] = useState(true); + const [textSelection, setTextSelection] = useState(null); + const [floatingButtonPos, setFloatingButtonPos] = useState(null); + const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null); + const [commentView, setCommentView] = useState(null); + const editorRef = useRef(null); + const wrapperRef = useRef(null); + const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const { toast } = useToast(); + + // Comment store + const addComment = useDiffCommentsStore((state) => state.addComment); + const removeComment = useDiffCommentsStore((state) => state.removeComment); + const comments = useFileComments(sessionId ?? '', path); + + const langExt = getCodeMirrorExtensionFromPath(path); + + // Create the comment decoration plugin + const commentPlugin = useMemo(() => { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = createCommentDecorations(view, comments); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = createCommentDecorations(update.view, comments); + } + } + }, + { decorations: (v) => v.decorations } + ); + }, [comments]); + + // Create comment gutter + const commentGutter = useMemo(() => { + // Build a map of line number to comments that include that line + const commentsByLine = new Map(); + // Track which lines are the first line of at least one comment + const firstLines = new Set(); + + for (const comment of comments) { + firstLines.add(comment.startLine); + // Add to all lines in the range + for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) { + const existing = commentsByLine.get(lineNum) || []; + existing.push(comment); + commentsByLine.set(lineNum, existing); + } + } + + return gutter({ + class: 'cm-comment-gutter', + lineMarker: (view, line) => { + const lineNum = view.state.doc.lineAt(line.from).number; + const lineComments = commentsByLine.get(lineNum); + if (lineComments && lineComments.length > 0) { + return new CommentGutterMarker(lineComments, firstLines.has(lineNum)); + } + return null; + }, + domEventHandlers: { + click: (view, line, event) => { + const lineNum = view.state.doc.lineAt(line.from).number; + const lineComments = commentsByLine.get(lineNum); + if (lineComments && lineComments.length > 0) { + event.preventDefault(); + event.stopPropagation(); + setCommentView({ + comments: lineComments, + position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY }, + }); + return true; + } + return false; + }, + }, + }); + }, [comments]); + + // Track mouse position + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + mousePositionRef.current = { x: e.clientX, y: e.clientY }; + }; + document.addEventListener('mousemove', handleMouseMove); + return () => document.removeEventListener('mousemove', handleMouseMove); + }, []); + + // Show floating button after selection ends (on mouseup) + const handleSelectionEnd = useCallback(() => { + if (!enableComments || !sessionId) return; + + const view = editorRef.current?.view; + if (!view) return; + + const selection = view.state.selection.main; + if (selection.empty) { + setFloatingButtonPos(null); + setCurrentSelection(null); + return; + } + + const selectedText = view.state.sliceDoc(selection.from, selection.to); + if (!selectedText.trim()) { + setFloatingButtonPos(null); + setCurrentSelection(null); + return; + } + + // Get line numbers + const startLine = view.state.doc.lineAt(selection.from).number; + const endLine = view.state.doc.lineAt(selection.to).number; + + // Use mouse position for the floating button (where cursor is) + setCurrentSelection({ text: selectedText, startLine, endLine }); + setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y }); + }, [enableComments, sessionId]); + + // Listen for mouseup to show floating button after selection ends + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper || !enableComments || !sessionId) return; + + const handleMouseUp = (e: MouseEvent) => { + // Don't show if clicking on the floating button itself + if ((e.target as HTMLElement).closest('.floating-comment-btn')) { + return; + } + // Small delay to let selection finalize + setTimeout(handleSelectionEnd, 10); + }; + + // Clear selection state when clicking (new selection starts) + const handleMouseDown = (e: MouseEvent) => { + // Don't clear if clicking on the floating button + if ((e.target as HTMLElement).closest('.floating-comment-btn')) { + return; + } + setFloatingButtonPos(null); + }; + + wrapper.addEventListener('mouseup', handleMouseUp); + wrapper.addEventListener('mousedown', handleMouseDown); + return () => { + wrapper.removeEventListener('mouseup', handleMouseUp); + wrapper.removeEventListener('mousedown', handleMouseDown); + }; + }, [enableComments, sessionId, handleSelectionEnd]); + + // Clear floating button when selection is cleared via keyboard + const selectionUpdateExtension = useMemo(() => { + return EditorView.updateListener.of((update) => { + if (update.selectionSet) { + const selection = update.state.selection.main; + if (selection.empty) { + setFloatingButtonPos(null); + setCurrentSelection(null); + } + } + }); + }, []); + + const extensions: Extension[] = useMemo(() => { + const exts: Extension[] = [ + EditorView.editable.of(true), + EditorView.theme({ + '&': { backgroundColor: 'hsl(var(--background)) !important' }, + '.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' }, + // Comment gutter + '.cm-comment-gutter': { + width: '22px', + cursor: 'pointer', + }, + '.cm-comment-gutter .cm-gutterElement': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0', + }, + // Marker styles - background color on the marker itself (indigo) + '.cm-comment-gutter-marker': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + cursor: 'pointer', + backgroundColor: 'rgba(99, 102, 241, 0.2)', + }, + '.cm-comment-gutter-marker:hover': { + backgroundColor: 'rgba(99, 102, 241, 0.35)', + }, + // Comment line highlight with left border (indigo) + '.cm-comment-line': { + backgroundColor: 'rgba(99, 102, 241, 0.15) !important', + borderLeft: '3px solid rgba(99, 102, 241, 0.6)', + marginLeft: '-3px', + paddingLeft: '3px', + }, + }), + commentGutter, + commentPlugin, + selectionUpdateExtension, + ]; + if (wrapEnabled) { + exts.push(EditorView.lineWrapping); + } + if (langExt) { + exts.push(langExt); + } + return exts; + }, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]); + + // Calculate diff stats when dirty + const diffStats = useMemo(() => { + if (!isDirty) return null; + + const originalLines = originalContent.split('\n'); + const currentLines = content.split('\n'); + + let additions = 0; + let deletions = 0; + + const maxLen = Math.max(originalLines.length, currentLines.length); + for (let i = 0; i < maxLen; i++) { + const origLine = originalLines[i]; + const currLine = currentLines[i]; + + if (origLine === undefined && currLine !== undefined) { + additions++; + } else if (origLine !== undefined && currLine === undefined) { + deletions++; + } else if (origLine !== currLine) { + additions++; + deletions++; + } + } + + return { additions, deletions }; + }, [isDirty, content, originalContent]); + + // Handle Cmd+I to open comment popover + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper || !enableComments || !sessionId) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux) + if ((e.metaKey || e.ctrlKey) && e.key === 'i') { + if (!currentSelection || !floatingButtonPos) return; + + e.preventDefault(); + e.stopPropagation(); + + setTextSelection({ + ...currentSelection, + position: floatingButtonPos, + }); + setFloatingButtonPos(null); + } + }; + + wrapper.addEventListener('keydown', handleKeyDown, true); + return () => wrapper.removeEventListener('keydown', handleKeyDown, true); + }, [enableComments, sessionId, currentSelection, floatingButtonPos]); + + // Handle Cmd/Ctrl+S keyboard shortcut and Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + if (isDirty && !isSaving) { + onSave(); + } + } + // Escape to close popovers + if (e.key === 'Escape') { + if (textSelection) setTextSelection(null); + if (commentView) setCommentView(null); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isDirty, isSaving, onSave, textSelection, commentView]); + + const handleChange = useCallback( + (value: string) => { + onChange(value); + }, + [onChange] + ); + + // Open comment popover from floating button + const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!currentSelection || !floatingButtonPos) return; + + setTextSelection({ + ...currentSelection, + position: floatingButtonPos, + }); + setFloatingButtonPos(null); + }, [currentSelection, floatingButtonPos]); + + // Submit comment + const handleCommentSubmit = useCallback((annotation: string) => { + if (!textSelection || !sessionId) return; + + const { text, startLine, endLine } = textSelection; + + const comment: DiffComment = { + id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + sessionId, + filePath: path, + startLine, + endLine, + side: 'additions', + codeContent: text, + annotation, + createdAt: new Date().toISOString(), + status: 'pending', + }; + + addComment(comment); + setTextSelection(null); + + // Clear selection in editor + const view = editorRef.current?.view; + if (view) { + view.dispatch({ + selection: { anchor: view.state.selection.main.head }, + }); + } + + toast({ + title: 'Comment added', + description: 'Your comment will be sent with your next message.', + }); + }, [textSelection, sessionId, path, addComment, toast]); + + // Close popover + const handlePopoverClose = useCallback(() => { + setTextSelection(null); + }, []); + + // Delete comment + const handleDeleteComment = useCallback((commentId: string) => { + if (!sessionId) return; + removeComment(sessionId, path, commentId); + + // Close the view if no more comments + if (commentView && commentView.comments.length <= 1) { + setCommentView(null); + } else if (commentView) { + setCommentView({ + ...commentView, + comments: commentView.comments.filter(c => c.id !== commentId), + }); + } + + toast({ + title: 'Comment deleted', + }); + }, [sessionId, path, removeComment, commentView, toast]); + + // Close comment view + const handleCommentViewClose = useCallback(() => { + setCommentView(null); + }, []); + + return ( +
+ {/* Editor header with save button */} +
+
+ {toRelativePath(path, worktreePath)} + {isDirty && diffStats && ( + + {formatDiffStats(diffStats.additions, diffStats.deletions)} + + )} +
+
+ {enableComments && sessionId && comments.length > 0 && ( +
+ + {comments.length} comment{comments.length > 1 ? 's' : ''} +
+ )} + + + + + {wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'} + + {onDelete && ( + + + + + Delete file + + )} + +
+
+ + {/* CodeMirror editor */} +
+ + + {/* Floating "Add comment" button when text is selected */} + {floatingButtonPos && !textSelection && ( + + )} + + {/* Comment popover for adding new comment */} + {textSelection && ( + + )} + + {/* Comment view popover for viewing/deleting existing comments */} + {commentView && ( + + )} +
+
+ ); +} diff --git a/apps/web/components/editors/file-actions-dropdown.tsx b/apps/web/components/editors/file-actions-dropdown.tsx new file mode 100644 index 0000000000..2c86007ce7 --- /dev/null +++ b/apps/web/components/editors/file-actions-dropdown.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useMemo, useCallback } from 'react'; +import { + IconExternalLink, + IconCopy, + IconFolderShare, +} from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@kandev/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { useEditors } from '@/hooks/domains/settings/use-editors'; +import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; +import { useOpenSessionFolder } from '@/hooks/use-open-session-folder'; +import { useAppStore } from '@/components/state-provider'; +import type { EditorOption } from '@/lib/types/http'; + +type FileActionsDropdownProps = { + /** File path to open / copy */ + filePath: string; + /** Session ID override — defaults to activeSessionId from store */ + sessionId?: string; + /** Button size variant */ + size?: 'sm' | 'xs'; + /** Optional toast callback after copy */ + onCopied?: () => void; +}; + +export function FileActionsDropdown({ + filePath, + sessionId: sessionIdProp, + size = 'xs', + onCopied, +}: FileActionsDropdownProps) { + const storeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const sessionId = sessionIdProp ?? storeSessionId ?? null; + const worktreePath = useAppStore((state) => { + if (!sessionId) return undefined; + return state.taskSessions.items[sessionId]?.worktree_path; + }); + + const openEditor = useOpenSessionInEditor(sessionId); + const openFolder = useOpenSessionFolder(sessionId); + const { editors } = useEditors(); + const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId); + + const enabledEditors = useMemo( + () => + editors.filter((editor: EditorOption) => { + if (!editor.enabled) return false; + if (editor.kind === 'built_in') return editor.installed; + return true; + }), + [editors] + ); + + const absolutePath = useMemo(() => { + if (!worktreePath) return filePath; + // Avoid double slashes + const base = worktreePath.endsWith('/') ? worktreePath : `${worktreePath}/`; + const rel = filePath.startsWith('/') ? filePath.slice(1) : filePath; + return `${base}${rel}`; + }, [worktreePath, filePath]); + + const handleCopyPath = useCallback(() => { + navigator.clipboard.writeText(absolutePath); + onCopied?.(); + }, [absolutePath, onCopied]); + + const handleOpenInEditor = useCallback( + (editorId: string) => { + void openEditor.open({ editorId, filePath }); + }, + [openEditor, filePath] + ); + + const handleOpenFolder = useCallback(() => { + void openFolder.open(); + }, [openFolder]); + + const btnClass = size === 'xs' + ? 'h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100' + : 'h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-foreground'; + + const iconClass = size === 'xs' ? 'h-3.5 w-3.5' : 'h-4 w-4'; + + return ( + + + + + + + + Open with... + + + {enabledEditors.map((editor: EditorOption) => ( + handleOpenInEditor(editor.id)} + > + {editor.name} + {editor.id === defaultEditorId && ( + default + )} + + ))} + {enabledEditors.length === 0 && ( + + No editors configured + + )} + + + + Copy path + + + + Open folder + + + + ); +} diff --git a/apps/web/components/editors/monaco/builtin-providers.ts b/apps/web/components/editors/monaco/builtin-providers.ts new file mode 100644 index 0000000000..b513d4cd71 --- /dev/null +++ b/apps/web/components/editors/monaco/builtin-providers.ts @@ -0,0 +1,16 @@ +// Shared state for controlling Monaco's built-in TS/JS provider suppression. +// Separated into its own module (no heavy imports) so both monaco-loader.ts +// and lsp-client-manager.ts can import it without circular dependencies or +// pulling in the full monaco-editor bundle. + +let _suppressed = false; + +/** Returns true when built-in TS/JS providers should be suppressed (LSP active). */ +export function isBuiltinTsSuppressed(): boolean { + return _suppressed; +} + +/** Set suppression state. Called synchronously from lsp-client-manager. */ +export function setBuiltinTsSuppressed(suppressed: boolean): void { + _suppressed = suppressed; +} diff --git a/apps/web/components/editors/monaco/monaco-code-block.tsx b/apps/web/components/editors/monaco/monaco-code-block.tsx new file mode 100644 index 0000000000..a280d53d96 --- /dev/null +++ b/apps/web/components/editors/monaco/monaco-code-block.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import Editor from '@monaco-editor/react'; +import { IconCheck, IconCopy } from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import { getMonacoLanguageFromName } from '@/lib/editor/language-map'; +import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; +import { initMonacoThemes } from './monaco-init'; + +initMonacoThemes(); + +type MonacoCodeBlockProps = { + children: React.ReactNode; + className?: string; +}; + +export function MonacoCodeBlock({ children, className }: MonacoCodeBlockProps) { + const { copied, copy } = useCopyToClipboard(); + const { resolvedTheme } = useTheme(); + + const code = String(children).replace(/\n$/, ''); + const lang = className?.replace('language-', '').toLowerCase() ?? ''; + const language = getMonacoLanguageFromName(lang); + const lineCount = code.split('\n').length; + // Compact height: ~16px per line + 8px padding + const height = Math.min(Math.max(lineCount * 16 + 8, 32), 500); + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx new file mode 100644 index 0000000000..1716d86320 --- /dev/null +++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx @@ -0,0 +1,719 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import Editor, { type OnMount, type OnChange } from '@monaco-editor/react'; +import type { editor as monacoEditor, IDisposable } from 'monaco-editor'; +import { useTheme } from 'next-themes'; +import { Button } from '@kandev/ui/button'; +import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus, IconArrowsDiff, IconPlugConnected, IconPlugOff, IconAlertTriangle } from '@tabler/icons-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { formatDiffStats } from '@/lib/utils/file-diff'; +import { toRelativePath } from '@/lib/utils'; +import { getMonacoLanguage } from '@/lib/editor/language-map'; +import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme'; +import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice'; +import type { DiffComment } from '@/lib/diff/types'; +import { useToast } from '@/components/toast-provider'; +import { EditorCommentPopover } from '@/components/task/editor-comment-popover'; +import { CommentViewPopover } from '@/components/task/comment-view-popover'; +import { diffLines } from 'diff'; +import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown'; +import { useAppStore } from '@/components/state-provider'; +import { useLsp } from '@/hooks/use-lsp'; +import type { LspStatus } from '@/lib/lsp/lsp-client-manager'; +import { lspClientManager } from '@/lib/lsp/lsp-client-manager'; +import { consumePendingCursorPosition } from '@/hooks/use-file-editors'; +import { initMonacoThemes } from './monaco-init'; + +initMonacoThemes(); + +type MonacoCodeEditorProps = { + path: string; + content: string; + originalContent: string; + isDirty: boolean; + isSaving: boolean; + sessionId?: string; + worktreePath?: string; + enableComments?: boolean; + onChange: (newContent: string) => void; + onSave: () => void; + onDelete?: () => void; +}; + +type TextSelection = { + text: string; + startLine: number; + endLine: number; + position: { x: number; y: number }; +} | null; + +type FloatingButtonPosition = { + x: number; + y: number; +} | null; + +type CommentViewState = { + comments: DiffComment[]; + position: { x: number; y: number }; +} | null; + +function LspStatusButton({ + status, + lspLanguage, + onToggle, +}: { + status: LspStatus; + lspLanguage: string | null; + onToggle: () => void; +}) { + if (!lspLanguage) return null; + + type StateConfig = { icon: React.ReactNode; tooltip: string; clickable: boolean }; + const configs: Record = { + disabled: { + icon: , + tooltip: 'LSP: Off \u2014 click to start', + clickable: true, + }, + connecting: { + icon: , + tooltip: 'LSP: Connecting...', + clickable: true, + }, + installing: { + icon: , + tooltip: 'LSP: Installing language server...', + clickable: false, + }, + starting: { + icon: , + tooltip: 'LSP: Starting language server...', + clickable: true, + }, + ready: { + icon: , + tooltip: 'LSP: Connected \u2014 click to stop', + clickable: true, + }, + stopping: { + icon: , + tooltip: 'LSP: Stopping...', + clickable: false, + }, + unavailable: { + icon: , + tooltip: `LSP: ${'reason' in status ? status.reason : 'Unavailable'}`, + clickable: true, + }, + error: { + icon: , + tooltip: `LSP: ${'reason' in status ? status.reason : 'Error'}`, + clickable: true, + }, + }; + + const c = configs[status.state]; + if (!c) return null; + + return ( + + + + + {c.tooltip} + + ); +} + +export function MonacoCodeEditor({ + path, + content, + originalContent, + isDirty, + isSaving, + sessionId, + worktreePath, + enableComments = false, + onChange, + onSave, + onDelete, +}: MonacoCodeEditorProps) { + const { resolvedTheme } = useTheme(); + const [wrapEnabled, setWrapEnabled] = useState(true); + const [textSelection, setTextSelection] = useState(null); + const [floatingButtonPos, setFloatingButtonPos] = useState(null); + const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null); + const [commentView, setCommentView] = useState(null); + const [showDiffIndicators, setShowDiffIndicators] = useState(true); + const editorRef = useRef(null); + const wrapperRef = useRef(null); + const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const decorationsRef = useRef(null); + const diffDecorationsRef = useRef(null); + const disposablesRef = useRef([]); + const { toast } = useToast(); + + const addComment = useDiffCommentsStore((state) => state.addComment); + const removeComment = useDiffCommentsStore((state) => state.removeComment); + const comments = useFileComments(sessionId ?? '', path); + + const language = getMonacoLanguage(path); + + // LSP integration + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const lspSessionId = sessionId ?? activeSessionId ?? null; + const { status: lspStatus, lspLanguage, toggle: toggleLsp } = useLsp(lspSessionId, language); + + const hasLspActive = lspStatus.state === 'ready'; + + // Use absolute path for Monaco model URI so the LSP server receives correct file:// URIs + const monacoPath = worktreePath ? `${worktreePath}/${path}` : path; + + // LSP document sync: open/close document when LSP becomes ready or component mounts/unmounts + const documentUri = `file://${monacoPath}`; + useEffect(() => { + if (!hasLspActive || !lspSessionId || !lspLanguage) return; + lspClientManager.openDocument(lspSessionId, lspLanguage, documentUri, language, content); + return () => { + lspClientManager.closeDocument(lspSessionId, lspLanguage, documentUri); + }; + // Only run on mount/unmount and when LSP readiness changes — NOT on content changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasLspActive, lspSessionId, lspLanguage, documentUri, language]); + + // LSP document change: notify server of content changes (debounced) + const changeTimerRef = useRef | null>(null); + useEffect(() => { + if (!hasLspActive || !lspSessionId || !lspLanguage) return; + if (changeTimerRef.current) clearTimeout(changeTimerRef.current); + changeTimerRef.current = setTimeout(() => { + lspClientManager.changeDocument(lspSessionId, lspLanguage, documentUri, content); + }, 300); + return () => { + if (changeTimerRef.current) clearTimeout(changeTimerRef.current); + }; + }, [hasLspActive, lspSessionId, lspLanguage, documentUri, content]); + + // Toast on LSP status changes + const lspStateForToast = lspStatus.state; + const lspReasonForToast = 'reason' in lspStatus ? lspStatus.reason : null; + useEffect(() => { + if (lspStateForToast === 'installing') { + toast({ + title: 'Installing language server', + description: 'This may take a moment...', + }); + } else if (lspStateForToast === 'unavailable' && lspReasonForToast) { + toast({ + title: 'Language server not found', + description: `${lspReasonForToast}. Enable auto-install in Settings → Editors.`, + }); + } else if (lspStateForToast === 'error' && lspReasonForToast) { + toast({ + title: 'LSP error', + description: lspReasonForToast, + }); + } + }, [lspStateForToast, lspReasonForToast, toast]); + + // Track mouse position + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + mousePositionRef.current = { x: e.clientX, y: e.clientY }; + }; + document.addEventListener('mousemove', handleMouseMove); + return () => document.removeEventListener('mousemove', handleMouseMove); + }, []); + + // Handle editor mount + const handleEditorDidMount: OnMount = useCallback( + (editor) => { + editorRef.current = editor; + decorationsRef.current = editor.createDecorationsCollection([]); + diffDecorationsRef.current = editor.createDecorationsCollection([]); + + // Jump to pending cursor position (e.g. from LSP Go-to-Definition) + const pendingPos = consumePendingCursorPosition(path); + if (pendingPos) { + editor.setPosition({ lineNumber: pendingPos.line, column: pendingPos.column }); + editor.revealLineInCenter(pendingPos.line); + } + + // Cmd/Ctrl+S to save + editor.addCommand( + // eslint-disable-next-line no-bitwise + (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyMod.CtrlCmd | + (window as { monaco?: typeof import('monaco-editor') }).monaco!.KeyCode.KeyS, + () => { + onSave(); + } + ); + + // Selection change listener for comments + if (enableComments && sessionId) { + const disposable = editor.onDidChangeCursorSelection(() => { + const selection = editor.getSelection(); + if (!selection || selection.isEmpty()) { + setCurrentSelection(null); + return; + } + const model = editor.getModel(); + if (!model) return; + const text = model.getValueInRange(selection); + if (!text.trim()) { + setCurrentSelection(null); + return; + } + setCurrentSelection({ + text, + startLine: selection.startLineNumber, + endLine: selection.endLineNumber, + }); + }); + disposablesRef.current.push(disposable); + } + + // Gutter click for viewing comments + const glyphDisposable = editor.onMouseDown((e) => { + if (e.target.type === 2 /* GUTTER_GLYPH_MARGIN */ || e.target.type === 3 /* GUTTER_LINE_NUMBERS */) { + const lineNumber = e.target.position?.lineNumber; + if (!lineNumber) return; + + const lineComments = comments.filter( + (c) => lineNumber >= c.startLine && lineNumber <= c.endLine + ); + if (lineComments.length > 0 && e.event.browserEvent) { + setCommentView({ + comments: lineComments, + position: { x: e.event.browserEvent.clientX, y: e.event.browserEvent.clientY }, + }); + } + } + }); + disposablesRef.current.push(glyphDisposable); + }, + [path, onSave, enableComments, sessionId, comments] + ); + + // Cleanup disposables + useEffect(() => { + return () => { + for (const d of disposablesRef.current) d.dispose(); + disposablesRef.current = []; + }; + }, []); + + // Update decorations when comments change + useEffect(() => { + if (!decorationsRef.current || !editorRef.current) return; + + const decorations: monacoEditor.IModelDeltaDecoration[] = []; + const linesWithComments = new Set(); + const firstLines = new Set(); + + for (const comment of comments) { + firstLines.add(comment.startLine); + for (let line = comment.startLine; line <= comment.endLine; line++) { + linesWithComments.add(line); + } + } + + for (const lineNum of linesWithComments) { + decorations.push({ + range: { + startLineNumber: lineNum, + startColumn: 1, + endLineNumber: lineNum, + endColumn: 1, + }, + options: { + isWholeLine: true, + className: 'monaco-comment-line', + glyphMarginClassName: firstLines.has(lineNum) + ? 'monaco-comment-glyph' + : 'monaco-comment-glyph-bg', + glyphMarginHoverMessage: { + value: `${comments.filter((c) => lineNum >= c.startLine && lineNum <= c.endLine).length} comment(s) - click to view`, + }, + }, + }); + } + + decorationsRef.current.set(decorations); + }, [comments]); + + // Diff gutter indicators + useEffect(() => { + if (!diffDecorationsRef.current || !editorRef.current) return; + + if (!showDiffIndicators || !isDirty || !originalContent) { + diffDecorationsRef.current.set([]); + return; + } + + const changes = diffLines(originalContent, content); + const decorations: monacoEditor.IModelDeltaDecoration[] = []; + let currentLine = 1; + + for (let i = 0; i < changes.length; i++) { + const change = changes[i]; + const lineCount = change.count ?? 0; + + if (change.removed) { + // Check if next change is an addition (modified lines) + const next = changes[i + 1]; + if (next && next.added) { + // Modified: removed + added pair + const addedLineCount = next.count ?? 0; + for (let j = 0; j < addedLineCount; j++) { + decorations.push({ + range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 }, + options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-modified-gutter' }, + }); + } + currentLine += addedLineCount; + i++; // skip the added part + } else { + // Standalone deletion: red indicator at previous line + const indicatorLine = Math.max(1, currentLine - 1); + decorations.push({ + range: { startLineNumber: indicatorLine, startColumn: 1, endLineNumber: indicatorLine, endColumn: 1 }, + options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-deleted-gutter' }, + }); + } + } else if (change.added) { + // Standalone addition + for (let j = 0; j < lineCount; j++) { + decorations.push({ + range: { startLineNumber: currentLine + j, startColumn: 1, endLineNumber: currentLine + j, endColumn: 1 }, + options: { isWholeLine: true, linesDecorationsClassName: 'monaco-diff-added-gutter' }, + }); + } + currentLine += lineCount; + } else { + // Unchanged + currentLine += lineCount; + } + } + + diffDecorationsRef.current.set(decorations); + }, [content, originalContent, showDiffIndicators, isDirty]); + + // Show floating button after mouse up + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper || !enableComments || !sessionId) return; + + const handleMouseUp = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('.floating-comment-btn')) return; + setTimeout(() => { + if (currentSelection) { + setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y }); + } + }, 10); + }; + + const handleMouseDown = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('.floating-comment-btn')) return; + setFloatingButtonPos(null); + }; + + wrapper.addEventListener('mouseup', handleMouseUp); + wrapper.addEventListener('mousedown', handleMouseDown); + return () => { + wrapper.removeEventListener('mouseup', handleMouseUp); + wrapper.removeEventListener('mousedown', handleMouseDown); + }; + }, [enableComments, sessionId, currentSelection]); + + // Cmd+I for comment popover + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper || !enableComments || !sessionId) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'i') { + if (!currentSelection || !floatingButtonPos) return; + e.preventDefault(); + e.stopPropagation(); + setTextSelection({ ...currentSelection, position: floatingButtonPos }); + setFloatingButtonPos(null); + } + }; + + wrapper.addEventListener('keydown', handleKeyDown, true); + return () => wrapper.removeEventListener('keydown', handleKeyDown, true); + }, [enableComments, sessionId, currentSelection, floatingButtonPos]); + + // Escape to close popovers + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (textSelection) setTextSelection(null); + if (commentView) setCommentView(null); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [textSelection, commentView]); + + const handleChange: OnChange = useCallback( + (value) => { + if (value !== undefined) onChange(value); + }, + [onChange] + ); + + const handleFloatingButtonClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!currentSelection || !floatingButtonPos) return; + setTextSelection({ ...currentSelection, position: floatingButtonPos }); + setFloatingButtonPos(null); + }, + [currentSelection, floatingButtonPos] + ); + + const handleCommentSubmit = useCallback( + (annotation: string) => { + if (!textSelection || !sessionId) return; + const comment: DiffComment = { + id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + sessionId, + filePath: path, + startLine: textSelection.startLine, + endLine: textSelection.endLine, + side: 'additions', + codeContent: textSelection.text, + annotation, + createdAt: new Date().toISOString(), + status: 'pending', + }; + addComment(comment); + setTextSelection(null); + + // Clear editor selection + const editor = editorRef.current; + if (editor) { + const pos = editor.getPosition(); + if (pos) editor.setSelection({ startLineNumber: pos.lineNumber, startColumn: pos.column, endLineNumber: pos.lineNumber, endColumn: pos.column }); + } + + toast({ title: 'Comment added', description: 'Your comment will be sent with your next message.' }); + }, + [textSelection, sessionId, path, addComment, toast] + ); + + const handlePopoverClose = useCallback(() => setTextSelection(null), []); + + const handleDeleteComment = useCallback( + (commentId: string) => { + if (!sessionId) return; + removeComment(sessionId, path, commentId); + if (commentView && commentView.comments.length <= 1) { + setCommentView(null); + } else if (commentView) { + setCommentView({ + ...commentView, + comments: commentView.comments.filter((c) => c.id !== commentId), + }); + } + toast({ title: 'Comment deleted' }); + }, + [sessionId, path, removeComment, commentView, toast] + ); + + const handleCommentViewClose = useCallback(() => setCommentView(null), []); + + // Diff stats + const diffStats = useMemo(() => { + if (!isDirty) return null; + const originalLines = originalContent.split('\n'); + const currentLines = content.split('\n'); + let additions = 0; + let deletions = 0; + const maxLen = Math.max(originalLines.length, currentLines.length); + for (let i = 0; i < maxLen; i++) { + const origLine = originalLines[i]; + const currLine = currentLines[i]; + if (origLine === undefined && currLine !== undefined) additions++; + else if (origLine !== undefined && currLine === undefined) deletions++; + else if (origLine !== currLine) { additions++; deletions++; } + } + return { additions, deletions }; + }, [isDirty, content, originalContent]); + + return ( +
+ {/* Editor header */} +
+
+ {toRelativePath(path, worktreePath)} + {isDirty && diffStats && ( + + {formatDiffStats(diffStats.additions, diffStats.deletions)} + + )} +
+
+ {enableComments && sessionId && comments.length > 0 && ( +
+ + {comments.length} comment{comments.length > 1 ? 's' : ''} +
+ )} + + {isDirty && ( + + + + + {showDiffIndicators ? 'Hide diff indicators' : 'Show diff indicators'} + + )} + + + + + {wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'} + + + {onDelete && ( + + + + + Delete file + + )} + +
+
+ + {/* Monaco editor */} +
+ + Loading editor... +
+ } + /> + + {/* Floating comment button */} + {floatingButtonPos && !textSelection && ( + + )} + + {textSelection && ( + + )} + + {commentView && ( + + )} +
+ + ); +} diff --git a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx new file mode 100644 index 0000000000..cfbeb175f7 --- /dev/null +++ b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx @@ -0,0 +1,555 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'; +import type { editor as monacoEditor } from 'monaco-editor'; +import { useTheme } from 'next-themes'; +import { cn } from '@kandev/ui/lib/utils'; +import { Button } from '@kandev/ui/button'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip'; +import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconPencil, IconArrowBackUp, IconFoldDown, IconFold } from '@tabler/icons-react'; +import type { FileDiffData, DiffComment } from '@/lib/diff/types'; +import { getMonacoLanguage } from '@/lib/editor/language-map'; +import { EDITOR_FONT_FAMILY, EDITOR_FONT_SIZE } from '@/lib/theme/editor-theme'; +import { useDiffComments } from '@/components/diff/use-diff-comments'; +import { CommentForm } from '@/components/diff/comment-form'; +import { CommentDisplay } from '@/components/diff/comment-display'; +import { initMonacoThemes } from './monaco-init'; + +initMonacoThemes(); + +// Global view mode sync (same as pierre diff-viewer) +const DIFF_VIEW_MODE_KEY = 'diff-view-mode'; +const DEFAULT_VIEW_MODE = 'unified' as const; +const VIEW_MODE_CHANGE_EVENT = 'diff-view-mode-change'; +type ViewMode = 'split' | 'unified'; + +function getStoredViewMode(): ViewMode { + if (typeof window === 'undefined') return DEFAULT_VIEW_MODE; + const stored = localStorage.getItem(DIFF_VIEW_MODE_KEY); + return stored === 'split' || stored === 'unified' ? stored : DEFAULT_VIEW_MODE; +} + +function setStoredViewMode(mode: ViewMode): void { + localStorage.setItem(DIFF_VIEW_MODE_KEY, mode); + window.dispatchEvent(new CustomEvent(VIEW_MODE_CHANGE_EVENT, { detail: mode })); +} + +function useGlobalViewMode(): [ViewMode, (mode: ViewMode) => void] { + const subscribe = useCallback((callback: () => void) => { + window.addEventListener(VIEW_MODE_CHANGE_EVENT, callback); + window.addEventListener('storage', callback); + return () => { + window.removeEventListener(VIEW_MODE_CHANGE_EVENT, callback); + window.removeEventListener('storage', callback); + }; + }, []); + const getSnapshot = useCallback(() => getStoredViewMode(), []); + const getServerSnapshot = useCallback(() => DEFAULT_VIEW_MODE, []); + const viewMode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return [viewMode, setStoredViewMode]; +} + +// Global folding toggle sync (same pattern as view mode) +const DIFF_FOLD_KEY = 'diff-fold-unchanged'; +const DEFAULT_FOLD = true; +const FOLD_CHANGE_EVENT = 'diff-fold-change'; + +function getStoredFolding(): boolean { + if (typeof window === 'undefined') return DEFAULT_FOLD; + const stored = localStorage.getItem(DIFF_FOLD_KEY); + if (stored === null) return DEFAULT_FOLD; + return stored === 'true'; +} + +function setStoredFolding(fold: boolean): void { + localStorage.setItem(DIFF_FOLD_KEY, String(fold)); + window.dispatchEvent(new CustomEvent(FOLD_CHANGE_EVENT, { detail: fold })); +} + +function useGlobalFolding(): [boolean, (fold: boolean) => void] { + const subscribe = useCallback((callback: () => void) => { + window.addEventListener(FOLD_CHANGE_EVENT, callback); + window.addEventListener('storage', callback); + return () => { + window.removeEventListener(FOLD_CHANGE_EVENT, callback); + window.removeEventListener('storage', callback); + }; + }, []); + const getSnapshot = useCallback(() => getStoredFolding(), []); + const getServerSnapshot = useCallback(() => DEFAULT_FOLD, []); + const foldUnchanged = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + return [foldUnchanged, setStoredFolding]; +} + +interface MonacoDiffViewerProps { + data: FileDiffData; + sessionId?: string; + onCommentAdd?: (comment: DiffComment) => void; + onCommentDelete?: (commentId: string) => void; + comments?: DiffComment[]; + className?: string; + compact?: boolean; + hideHeader?: boolean; + onOpenFile?: (filePath: string) => void; + onRevert?: (filePath: string) => void; + wordWrap?: boolean; +} + +export function MonacoDiffViewer({ + data, + sessionId, + onCommentAdd, + onCommentDelete, + comments: externalComments, + className, + compact = false, + hideHeader = false, + onOpenFile, + onRevert, + wordWrap: wordWrapProp, +}: MonacoDiffViewerProps) { + const { resolvedTheme } = useTheme(); + const [globalViewMode, setGlobalViewMode] = useGlobalViewMode(); + const [foldUnchanged, setFoldUnchanged] = useGlobalFolding(); + const [wordWrapLocal, setWordWrap] = useState(false); + const wordWrap = wordWrapProp ?? wordWrapLocal; + const diffEditorRef = useRef(null); + const wrapperRef = useRef(null); + + // Context menu state + type ContextMenuState = { + x: number; + y: number; + lineNumber: number; + side: 'original' | 'modified'; + isChangedLine: boolean; + lineContent: string; + } | null; + const [contextMenu, setContextMenu] = useState(null); + + // Comment form state + const [showCommentForm, setShowCommentForm] = useState(false); + const [selectedLineRange, setSelectedLineRange] = useState<{ start: number; end: number; side: string } | null>(null); + + const { + comments: internalComments, + addComment, + removeComment, + updateComment, + editingCommentId, + setEditingComment, + } = useDiffComments({ + sessionId: sessionId || '', + filePath: data.filePath, + diff: data.diff, + newContent: data.newContent, + oldContent: data.oldContent, + }); + + const comments = externalComments || internalComments; + + const language = getMonacoLanguage(data.filePath); + const showHeader = !hideHeader && !compact; + + // Parse original/modified content from diff data + // When only a unified diff string is provided (e.g. git diffs), reconstruct + // the original and modified content by parsing the patch hunks. + const { original, modified } = useMemo(() => { + if (data.oldContent || data.newContent) { + return { + original: data.oldContent ?? '', + modified: data.newContent ?? '', + }; + } + + // Reconstruct from unified diff patch + if (data.diff) { + const originalLines: string[] = []; + const modifiedLines: string[] = []; + const lines = data.diff.split('\n'); + + for (const line of lines) { + // Skip diff headers + if (line.startsWith('diff ') || line.startsWith('index ') || + line.startsWith('--- ') || line.startsWith('+++ ') || + line.startsWith('@@')) { + continue; + } + + if (line.startsWith('-')) { + originalLines.push(line.slice(1)); + } else if (line.startsWith('+')) { + modifiedLines.push(line.slice(1)); + } else if (line.startsWith(' ') || line === '') { + // Context line (or empty line between hunks) + const content = line.startsWith(' ') ? line.slice(1) : line; + originalLines.push(content); + modifiedLines.push(content); + } else if (line.startsWith('\\')) { + // "\ No newline at end of file" — skip + continue; + } + } + + return { + original: originalLines.join('\n'), + modified: modifiedLines.join('\n'), + }; + } + + return { original: '', modified: '' }; + }, [data.oldContent, data.newContent, data.diff]); + + const handleDiffEditorMount: DiffOnMount = useCallback((editor) => { + diffEditorRef.current = editor; + + // Register context menu on both sides + const modifiedEditor = editor.getModifiedEditor(); + const originalEditor = editor.getOriginalEditor(); + + const handleContextMenu = (side: 'original' | 'modified') => (e: monacoEditor.IEditorMouseEvent) => { + if (e.target.position) { + e.event.preventDefault(); + e.event.stopPropagation(); + const lineNumber = e.target.position.lineNumber; + const targetEditor = side === 'modified' ? modifiedEditor : originalEditor; + const model = targetEditor.getModel(); + const lineContent = model ? model.getLineContent(lineNumber) : ''; + const lineChanges = editor.getLineChanges(); + let isChangedLine = false; + if (lineChanges) { + for (const change of lineChanges) { + if (side === 'modified') { + if (lineNumber >= change.modifiedStartLineNumber && lineNumber <= change.modifiedEndLineNumber) { + isChangedLine = true; + break; + } + } else { + if (lineNumber >= change.originalStartLineNumber && lineNumber <= change.originalEndLineNumber) { + isChangedLine = true; + break; + } + } + } + } + setContextMenu({ + x: e.event.posx, + y: e.event.posy, + lineNumber, + side, + isChangedLine, + lineContent, + }); + } + }; + + modifiedEditor.onContextMenu(handleContextMenu('modified')); + originalEditor.onContextMenu(handleContextMenu('original')); + }, []); + + // Handle comment submission + const handleCommentSubmit = useCallback( + (content: string) => { + if (!selectedLineRange) return; + if (onCommentAdd && externalComments !== undefined) { + const comment: DiffComment = { + id: `${data.filePath}-${Date.now()}`, + sessionId: sessionId || '', + filePath: data.filePath, + startLine: Math.min(selectedLineRange.start, selectedLineRange.end), + endLine: Math.max(selectedLineRange.start, selectedLineRange.end), + side: (selectedLineRange.side || 'additions') as DiffComment['side'], + codeContent: '', + annotation: content, + createdAt: new Date().toISOString(), + status: 'pending', + }; + onCommentAdd(comment); + } else if (sessionId) { + addComment( + { + start: selectedLineRange.start, + end: selectedLineRange.end, + side: selectedLineRange.side as 'additions' | 'deletions', + }, + content + ); + } + setShowCommentForm(false); + setSelectedLineRange(null); + }, + [selectedLineRange, sessionId, data.filePath, addComment, onCommentAdd, externalComments] + ); + + const handleCommentDelete = useCallback( + (commentId: string) => { + if (onCommentDelete && externalComments !== undefined) { + onCommentDelete(commentId); + } else { + removeComment(commentId); + } + }, + [removeComment, onCommentDelete, externalComments] + ); + + const handleCommentUpdate = useCallback( + (commentId: string, content: string) => { + updateComment(commentId, { annotation: content }); + setEditingComment(null); + }, + [updateComment, setEditingComment] + ); + + // Close context menu on click or Escape + useEffect(() => { + if (!contextMenu) return; + const handleClose = () => setContextMenu(null); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setContextMenu(null); + }; + window.addEventListener('mousedown', handleClose); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('mousedown', handleClose); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [contextMenu]); + + // Copy all changed lines from the diff + const copyAllChangedLines = useCallback(() => { + const editor = diffEditorRef.current; + if (!editor) return; + const lineChanges = editor.getLineChanges(); + if (!lineChanges) return; + const modifiedModel = editor.getModifiedEditor().getModel(); + if (!modifiedModel) return; + const changedLines: string[] = []; + for (const change of lineChanges) { + if (change.modifiedStartLineNumber <= change.modifiedEndLineNumber) { + for (let i = change.modifiedStartLineNumber; i <= change.modifiedEndLineNumber; i++) { + changedLines.push(modifiedModel.getLineContent(i)); + } + } + } + navigator.clipboard.writeText(changedLines.join('\n')); + setContextMenu(null); + }, []); + + if (!data.oldContent && !data.newContent && !data.diff) { + return ( +
+ No diff available +
+ ); + } + + return ( +
+ {/* Toolbar */} + {showHeader && ( +
+ {data.filePath} +
+ + + + + Copy diff + + + + + + {foldUnchanged ? 'Show all lines' : 'Fold unchanged lines'} + + {onRevert && ( + + + + + Revert changes + + )} + + + + + Toggle word wrap + + + + + + {globalViewMode === 'split' ? 'Switch to unified view' : 'Switch to split view'} + + {onOpenFile && ( + + + + + Edit + + )} +
+
+ )} + + {/* Diff editor */} +
+ + Loading diff... +
+ } + /> +
+ + {/* Comment annotations below the diff */} + {comments.length > 0 && ( +
+ {comments.map((comment) => ( +
+ {editingCommentId === comment.id ? ( + handleCommentUpdate(comment.id, content)} + onCancel={() => setEditingComment(null)} + isEditing + /> + ) : ( + handleCommentDelete(comment.id)} + onEdit={() => setEditingComment(comment.id)} + showCode={false} + /> + )} +
+ ))} +
+ )} + + {/* New comment form */} + {showCommentForm && selectedLineRange && ( +
+ { + setShowCommentForm(false); + setSelectedLineRange(null); + }} + /> +
+ )} + + {/* Context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + + {contextMenu.isChangedLine && ( + + )} + {onRevert && ( + <> +
+ + + )} +
+ )} +
+ ); +} diff --git a/apps/web/components/editors/monaco/monaco-init.ts b/apps/web/components/editors/monaco/monaco-init.ts new file mode 100644 index 0000000000..8abfc3e1f3 --- /dev/null +++ b/apps/web/components/editors/monaco/monaco-init.ts @@ -0,0 +1,108 @@ +import type { Monaco } from '@monaco-editor/react'; +import type { editor as monacoEditor, Uri, IRange, IPosition } from 'monaco-editor'; +import { KANDEV_MONACO_DARK, KANDEV_MONACO_LIGHT } from '@/lib/theme/editor-theme'; + +let initialized = false; +let monacoInstance: Monaco | null = null; + +export function initMonacoThemes() { + if (initialized || typeof window === 'undefined') return; + initialized = true; + + // Dynamic import ensures monaco-loader runs (which sets MonacoEnvironment + + // loader.config) before any component mounts. + import('./monaco-loader').then(({ monaco }) => { + monacoInstance = monaco; + + monaco.editor.defineTheme('kandev-dark', KANDEV_MONACO_DARK); + monaco.editor.defineTheme('kandev-light', KANDEV_MONACO_LIGHT); + + // Disable Monaco's built-in TS/JS diagnostics — they can't resolve project-level + // path aliases (@/, tsconfig paths). LSP diagnostics are applied separately via + // setModelMarkers() and work independently of this setting. + setMonacoDiagnostics(false); + + // Enable JSX so .tsx/.jsx files parse correctly + const tsDefaults = monaco.languages.typescript.typescriptDefaults; + const jsDefaults = monaco.languages.typescript.javascriptDefaults; + tsDefaults.setCompilerOptions({ + jsx: monaco.languages.typescript.JsxEmit.ReactJSX, + allowJs: true, + allowNonTsExtensions: true, + target: monaco.languages.typescript.ScriptTarget.ESNext, + module: monaco.languages.typescript.ModuleKind.ESNext, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + }); + jsDefaults.setCompilerOptions({ + jsx: monaco.languages.typescript.JsxEmit.ReactJSX, + allowJs: true, + allowNonTsExtensions: true, + target: monaco.languages.typescript.ScriptTarget.ESNext, + module: monaco.languages.typescript.ModuleKind.ESNext, + }); + + // Register editor opener to intercept Go-to-Definition navigation. + // When the LSP returns a file:// URI, this opens the file in a dockview tab + // instead of Monaco trying to navigate to a non-existent model. + // Dynamic import to avoid circular dependency (lsp-client-manager → monaco-init). + import('@/lib/lsp/lsp-client-manager').then(({ lspClientManager }) => { + monaco.editor.registerEditorOpener({ + openCodeEditor(_source: monacoEditor.ICodeEditor, resource: Uri, selectionOrPosition?: IRange | IPosition) { + const uri = resource.toString(); + const opener = lspClientManager.getFileOpener(); + if (opener && uri.startsWith('file:')) { + // Extract line/column from the selection or position + let line: number | undefined; + let column: number | undefined; + if (selectionOrPosition) { + if ('lineNumber' in selectionOrPosition) { + // IPosition + line = selectionOrPosition.lineNumber; + column = selectionOrPosition.column; + } else if ('startLineNumber' in selectionOrPosition) { + // IRange + line = selectionOrPosition.startLineNumber; + column = selectionOrPosition.startColumn; + } + } + opener(uri, line, column); + return true; + } + return false; + }, + }); + }); + }); +} + +export function getMonacoInstance(): Monaco | null { + return monacoInstance; +} + +export function setMonacoDiagnostics(enabled: boolean) { + if (!monacoInstance) return; + const tsDefaults = monacoInstance.languages.typescript.typescriptDefaults; + const jsDefaults = monacoInstance.languages.typescript.javascriptDefaults; + const diagOptions = { + noSemanticValidation: !enabled, + noSuggestionDiagnostics: !enabled, + noSyntaxValidation: !enabled, + }; + tsDefaults.setDiagnosticsOptions(diagOptions); + jsDefaults.setDiagnosticsOptions(diagOptions); +} + +/** + * Suppress or restore Monaco's built-in TS/JS language providers (hover, + * completions, definitions, references, etc.). When LSP is active these + * should be suppressed to avoid duplicate results. + * + * Uses the shared flag in builtin-providers.ts which is checked both at + * registration time (to decide whether to wrap a provider) and at call time + * (to decide whether wrapped providers return empty results). + */ +export function setMonacoBuiltinProviders(enabled: boolean) { + import('./builtin-providers').then(({ setBuiltinTsSuppressed }) => { + setBuiltinTsSuppressed(!enabled); + }); +} diff --git a/apps/web/components/editors/monaco/monaco-inline-diff.tsx b/apps/web/components/editors/monaco/monaco-inline-diff.tsx new file mode 100644 index 0000000000..d9debea30c --- /dev/null +++ b/apps/web/components/editors/monaco/monaco-inline-diff.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useMemo } from 'react'; +import { DiffEditor } from '@monaco-editor/react'; +import { useTheme } from 'next-themes'; +import { cn } from '@/lib/utils'; +import type { FileDiffData } from '@/lib/diff/types'; +import { getMonacoLanguage } from '@/lib/editor/language-map'; +import { EDITOR_FONT_FAMILY } from '@/lib/theme/editor-theme'; +import { initMonacoThemes } from './monaco-init'; + +initMonacoThemes(); + +type MonacoInlineDiffProps = { + data: FileDiffData; + className?: string; +}; + +export function MonacoInlineDiff({ data, className }: MonacoInlineDiffProps) { + const { resolvedTheme } = useTheme(); + const language = getMonacoLanguage(data.filePath); + + const { original, modified } = useMemo(() => { + if (data.oldContent || data.newContent) { + return { + original: data.oldContent ?? '', + modified: data.newContent ?? '', + }; + } + + // Reconstruct from unified diff patch + if (data.diff) { + const originalLines: string[] = []; + const modifiedLines: string[] = []; + for (const line of data.diff.split('\n')) { + if (line.startsWith('diff ') || line.startsWith('index ') || + line.startsWith('--- ') || line.startsWith('+++ ') || + line.startsWith('@@') || line.startsWith('\\')) continue; + if (line.startsWith('-')) { + originalLines.push(line.slice(1)); + } else if (line.startsWith('+')) { + modifiedLines.push(line.slice(1)); + } else { + const content = line.startsWith(' ') ? line.slice(1) : line; + originalLines.push(content); + modifiedLines.push(content); + } + } + return { original: originalLines.join('\n'), modified: modifiedLines.join('\n') }; + } + + return { original: '', modified: '' }; + }, [data.oldContent, data.newContent, data.diff]); + + const lineCount = Math.max( + original.split('\n').length, + modified.split('\n').length + ); + const height = Math.min(Math.max(lineCount * 16 + 8, 48), 300); + + return ( +
+ +
+ ); +} diff --git a/apps/web/components/editors/monaco/monaco-loader.ts b/apps/web/components/editors/monaco/monaco-loader.ts new file mode 100644 index 0000000000..d0974ff296 --- /dev/null +++ b/apps/web/components/editors/monaco/monaco-loader.ts @@ -0,0 +1,116 @@ +import * as monacoImport from 'monaco-editor'; +import type { Monaco } from '@monaco-editor/react'; +import type { IDisposable } from 'monaco-editor'; +import { loader } from '@monaco-editor/react'; +import { isBuiltinTsSuppressed } from './builtin-providers'; + +// Cast to Monaco type (from @monaco-editor/react) which has the full +// languages.typescript typings. The main 'monaco-editor' export marks +// languages.typescript as deprecated in favor of top-level typescript +// namespace, but @monaco-editor/react's Monaco type still uses it. +const monaco = monacoImport as unknown as Monaco; + +// --------------------------------------------------------------------------- +// Built-in TS/JS provider suppression +// --------------------------------------------------------------------------- +// Monaco v0.55.1's setModeConfiguration fires onDidChange, but the +// tsMode.js setupMode() function never subscribes to it — providers are +// registered once and never torn down. So we intercept provider registration +// to wrap built-in TS/JS providers with a suppression flag check. When LSP +// is active, the wrappers return null/empty instead of calling the original. +// +// Key: we check `isBuiltinTsSuppressed()` at REGISTRATION time. If the flag +// is already true when a provider is registered, it means the LSP client is +// registering its own providers — those should NOT be wrapped. Only providers +// registered while the flag is false (Monaco's built-in ones) get wrapped. +// --------------------------------------------------------------------------- + +const TS_LANGUAGES = new Set([ + 'typescript', + 'javascript', + 'typescriptreact', + 'javascriptreact', +]); + +// Configure workers before Monaco initializes. +// The `new URL('...', import.meta.url)` pattern is recognized by Turbopack/webpack +// to bundle each worker as a separate chunk. +if (typeof window !== 'undefined') { + self.MonacoEnvironment = { + getWorker(_workerId: string, label: string) { + switch (label) { + case 'json': + return new Worker(new URL('monaco-editor/esm/vs/language/json/json.worker.js', import.meta.url), { type: 'module' }); + case 'css': + case 'scss': + case 'less': + return new Worker(new URL('monaco-editor/esm/vs/language/css/css.worker.js', import.meta.url), { type: 'module' }); + case 'html': + case 'handlebars': + case 'razor': + return new Worker(new URL('monaco-editor/esm/vs/language/html/html.worker.js', import.meta.url), { type: 'module' }); + case 'typescript': + case 'javascript': + return new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url), { type: 'module' }); + default: + return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { type: 'module' }); + } + }, + }; + + // Tell @monaco-editor/react to use local monaco instance (no CDN fetch) + loader.config({ monaco: monacoImport }); + + // Expose on window for code that accesses window.monaco (e.g. keybindings in editor) + (window as { monaco?: Monaco }).monaco = monaco; + + // Wrap built-in TS/JS provider registration so we can suppress them at call + // time when an external LSP is active. This MUST happen before the first TS + // model is created (which triggers the lazy tsMode setup), and since this + // module runs at import time, it's guaranteed to be in place early enough. + const langs = monacoImport.languages; + + type ProviderRegistrationFn = (selector: string, provider: Record, ...rest: unknown[]) => IDisposable; + + function wrapRegistration( + original: ProviderRegistrationFn, + methodName: string, + emptyResult: unknown, + ): ProviderRegistrationFn { + return function (selector: string, provider: Record, ...rest: unknown[]) { + // Only wrap providers for TS/JS languages that are registered while + // suppression is OFF (= Monaco's built-in providers). When suppression + // is already ON at registration time, it means the LSP client is + // registering its own providers — pass those through unwrapped. + if ( + typeof selector === 'string' && + TS_LANGUAGES.has(selector) && + typeof provider[methodName] === 'function' && + !isBuiltinTsSuppressed() + ) { + const origMethod = (provider[methodName] as (...a: unknown[]) => unknown).bind(provider); + const wrapped = Object.create(provider); + wrapped[methodName] = function (...args: unknown[]) { + if (isBuiltinTsSuppressed()) return emptyResult; + return origMethod(...args); + }; + return original.call(langs, selector, wrapped, ...rest); + } + return original.call(langs, selector, provider, ...rest); + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- monkey-patching Monaco internals + const l = langs as Record; + l.registerHoverProvider = wrapRegistration(l.registerHoverProvider.bind(langs), 'provideHover', null); + l.registerCompletionItemProvider = wrapRegistration(l.registerCompletionItemProvider.bind(langs), 'provideCompletionItems', { suggestions: [] }); + l.registerSignatureHelpProvider = wrapRegistration(l.registerSignatureHelpProvider.bind(langs), 'provideSignatureHelp', null); + l.registerDefinitionProvider = wrapRegistration(l.registerDefinitionProvider.bind(langs), 'provideDefinition', null); + l.registerReferenceProvider = wrapRegistration(l.registerReferenceProvider.bind(langs), 'provideReferences', null); + l.registerDocumentHighlightProvider = wrapRegistration(l.registerDocumentHighlightProvider.bind(langs), 'provideDocumentHighlights', null); + l.registerCodeActionProvider = wrapRegistration(l.registerCodeActionProvider.bind(langs), 'provideCodeActions', null); + l.registerRenameProvider = wrapRegistration(l.registerRenameProvider.bind(langs), 'provideRenameEdits', null); + l.registerInlayHintsProvider = wrapRegistration(l.registerInlayHintsProvider.bind(langs), 'provideInlayHints', null); +} + +export { monaco }; diff --git a/apps/web/components/editors/pierre/pierre-diff-viewer.tsx b/apps/web/components/editors/pierre/pierre-diff-viewer.tsx new file mode 100644 index 0000000000..c6d06da4dd --- /dev/null +++ b/apps/web/components/editors/pierre/pierre-diff-viewer.tsx @@ -0,0 +1,5 @@ +'use client'; + +// Re-export the existing Pierre/diffs-based DiffViewer as PierreDiffViewer +// The original implementation stays in components/diff/diff-viewer.tsx +export { DiffViewer as PierreDiffViewer, DiffViewInline as PierreInlineDiff } from '@/components/diff/diff-viewer'; diff --git a/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts b/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts new file mode 100644 index 0000000000..d1a07d2f14 --- /dev/null +++ b/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts @@ -0,0 +1,221 @@ +/** + * TipTap extension for comment highlight decorations. + * Ported from the Milkdown ProseMirror plugin in markdown-editor.tsx. + */ + +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; + +export type CommentHighlight = { + id: string; + selectedText: string; + comment: string; +}; + +const MIN_COMMENT_TEXT_LENGTH = 3; + +const commentHighlightPluginKey = new PluginKey<{ + deco: DecorationSet; + comments: CommentHighlight[]; +}>('tiptap-comment-highlight'); + +// Helper to get plain text content and position mapping from a document +function getTextWithPositions(doc: { descendants: (fn: (node: { isText: boolean; text?: string; type: { name: string }; isBlock: boolean }, pos: number) => void) => void }): { text: string; positions: number[] } { + let text = ''; + const positions: number[] = []; + + doc.descendants((node, pos) => { + if (node.isText && node.text) { + for (let i = 0; i < node.text.length; i++) { + positions.push(pos + i); + text += node.text[i]; + } + } else if (node.type.name === 'hardBreak') { + positions.push(-1); + text += ' '; + } else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) { + positions.push(-1); + text += '\n'; + } + }); + + return { text, positions }; +} + +function normalizeForSearch(text: string): string { + return text + .replace(/\r\n/g, '\n') + .replace(/[\t ]+/g, ' ') + .replace(/\n+/g, '\n') + .trim() + .toLowerCase(); +} + +function createCommentDecorations( + doc: Parameters[0], + comments: CommentHighlight[] +): DecorationSet { + const decorations: Decoration[] = []; + + if (comments.length === 0) { + return DecorationSet.empty; + } + + const { text: fullText, positions } = getTextWithPositions(doc); + const normalizedFullText = normalizeForSearch(fullText); + + for (const comment of comments) { + const searchText = normalizeForSearch(comment.selectedText); + if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) continue; + + let normalizedIndex = normalizedFullText.indexOf(searchText); + + if (normalizedIndex === -1) { + const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]); + if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) { + normalizedIndex = normalizedFullText.indexOf(firstLine); + if (normalizedIndex !== -1) { + addDecorationsForRange(normalizedIndex, firstLine.length, comment, fullText, positions, decorations); + } + } + continue; + } + + addDecorationsForRange(normalizedIndex, searchText.length, comment, fullText, positions, decorations); + } + + return DecorationSet.create(doc, decorations); +} + +function addDecorationsForRange( + normalizedIndex: number, + length: number, + comment: CommentHighlight, + fullText: string, + positions: number[], + decorations: Decoration[] +) { + const normalizedFullText = normalizeForSearch(fullText); + const normalizedChars = normalizedFullText.split(''); + const origChars = fullText.split(''); + + const normToOrig: number[] = []; + let oi = 0; + for (let ni = 0; ni < normalizedChars.length; ni++) { + while ( + oi < origChars.length && + origChars[oi].toLowerCase() !== normalizedChars[ni] && + /\s/.test(origChars[oi]) + ) { + oi++; + } + normToOrig[ni] = oi; + oi++; + } + + const startOrig = normToOrig[normalizedIndex] ?? 0; + const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig; + + let rangeStart = -1; + + for (let i = startOrig; i <= endOrig && i < positions.length; i++) { + const pos = positions[i]; + + if (pos === -1) { + if (rangeStart !== -1) { + const fromPos = positions[rangeStart]; + const toPos = positions[i - 1] + 1; + if (fromPos >= 0 && toPos > fromPos) { + decorations.push( + Decoration.inline(fromPos, toPos, { + class: 'comment-highlight', + 'data-comment-id': comment.id, + title: comment.comment, + }) + ); + } + rangeStart = -1; + } + } else { + if (rangeStart === -1) { + rangeStart = i; + } + } + } + + if (rangeStart !== -1 && rangeStart < positions.length) { + const fromPos = positions[rangeStart]; + const lastValidIndex = Math.min(endOrig, positions.length - 1); + const toPos = positions[lastValidIndex] + 1; + if (fromPos >= 0 && toPos > fromPos) { + decorations.push( + Decoration.inline(fromPos, toPos, { + class: 'comment-highlight', + 'data-comment-id': comment.id, + title: comment.comment, + }) + ); + } + } +} + +export const CommentHighlightExtension = Extension.create({ + name: 'commentHighlight', + + addOptions() { + return { + comments: [] as CommentHighlight[], + }; + }, + + addProseMirrorPlugins() { + const initialComments = this.options.comments as CommentHighlight[]; + + return [ + new Plugin({ + key: commentHighlightPluginKey, + state: { + init(_, state) { + return { + deco: createCommentDecorations(state.doc, initialComments), + comments: initialComments, + }; + }, + apply(tr, value, _, newState) { + const newComments = tr.getMeta(commentHighlightPluginKey); + if (newComments !== undefined) { + return { + deco: createCommentDecorations(newState.doc, newComments), + comments: newComments, + }; + } + if (tr.docChanged) { + return { + deco: createCommentDecorations(newState.doc, value.comments), + comments: value.comments, + }; + } + return value; + }, + }, + props: { + decorations(state) { + const pluginState = commentHighlightPluginKey.getState(state); + return pluginState?.deco ?? DecorationSet.empty; + }, + }, + }), + ]; + }, +}); + +// Helper to update comments from outside +export function updateCommentHighlights( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor: { view: { dispatch: (tr: any) => void; state: { tr: { setMeta: (key: PluginKey, value: unknown) => any } } } }, + comments: CommentHighlight[] +) { + const tr = editor.view.state.tr.setMeta(commentHighlightPluginKey, comments); + editor.view.dispatch(tr); +} diff --git a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts new file mode 100644 index 0000000000..363aa79db3 --- /dev/null +++ b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts @@ -0,0 +1,104 @@ +/** + * TipTap node view extension for rendering mermaid diagrams. + * Ported from the Milkdown mermaidCodeBlockView in markdown-editor.tsx. + */ + +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; +import { createElement, useEffect, useRef, useState } from 'react'; +import mermaid from 'mermaid'; + +let mermaidInitialized = false; +let mermaidIdCounter = 0; + +function initMermaid(theme: 'dark' | 'light' = 'dark') { + mermaid.initialize({ + startOnLoad: false, + theme: theme === 'dark' ? 'dark' : 'default', + securityLevel: 'loose', + }); + mermaidInitialized = true; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function MermaidNodeView({ node }: { node: any }) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + const code = node.textContent; + + useEffect(() => { + if (!mermaidInitialized) initMermaid(); + if (!containerRef.current || !code.trim()) return; + + const id = `mermaid-${++mermaidIdCounter}`; + mermaid + .render(id, code) + .then(({ svg }) => { + if (containerRef.current) { + containerRef.current.innerHTML = svg; + setError(null); + } + }) + .catch((err: Error) => { + setError(err.message); + }); + }, [code]); + + return createElement( + NodeViewWrapper, + { className: 'mermaid-container' }, + error + ? createElement('pre', { className: 'mermaid-error' }, `Error rendering diagram: ${error}`) + : createElement('div', { ref: containerRef }) + ); +} + +/** + * Custom CodeBlock extension that renders mermaid blocks as diagrams. + * For non-mermaid blocks, uses default rendering. + */ +export const MermaidCodeBlock = Node.create({ + name: 'mermaidCodeBlock', + group: 'block', + content: 'text*', + marks: '', + code: true, + defining: true, + + addAttributes() { + return { + language: { + default: null, + parseHTML: (element) => element.getAttribute('data-language'), + renderHTML: (attributes) => { + if (!attributes.language) return {}; + return { 'data-language': attributes.language }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'pre', + preserveWhitespace: 'full', + getAttrs: (node) => { + const el = node as HTMLElement; + const code = el.querySelector('code'); + const lang = code?.className?.match(/language-(\w+)/)?.[1]; + if (lang === 'mermaid') return { language: 'mermaid' }; + return false; // Let default code block handle non-mermaid + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['pre', mergeAttributes(HTMLAttributes), ['code', {}, 0]]; + }, + + addNodeView() { + return ReactNodeViewRenderer(MermaidNodeView); + }, +}); diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx new file mode 100644 index 0000000000..2a8e988f27 --- /dev/null +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useTheme } from 'next-themes'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Placeholder from '@tiptap/extension-placeholder'; +import Link from '@tiptap/extension-link'; +import TaskList from '@tiptap/extension-task-list'; +import TaskItem from '@tiptap/extension-task-item'; +import { Table } from '@tiptap/extension-table'; +import { TableRow } from '@tiptap/extension-table-row'; +import { TableCell } from '@tiptap/extension-table-cell'; +import { TableHeader } from '@tiptap/extension-table-header'; +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; +import { Markdown } from 'tiptap-markdown'; +import { common, createLowlight } from 'lowlight'; +import { CommentHighlightExtension, updateCommentHighlights, type CommentHighlight } from './tiptap-comment-highlight'; + +export type { CommentHighlight }; + +export type TextSelection = { + text: string; + position: { x: number; y: number }; +}; + +type TipTapPlanEditorProps = { + value: string; + onChange: (value: string) => void; + placeholder?: string; + onSelectionChange?: (selection: TextSelection | null) => void; + onSelectionEnd?: (selection: TextSelection | null) => void; + comments?: CommentHighlight[]; + onCommentClick?: (id: string, position: { x: number; y: number }) => void; +}; + +const MIN_COMMENT_TEXT_LENGTH = 3; + +const lowlight = createLowlight(common); + +export function TipTapPlanEditor({ + value, + onChange, + placeholder = 'Start typing...', + onSelectionChange, + onSelectionEnd, + comments = [], + onCommentClick, +}: TipTapPlanEditorProps) { + const { resolvedTheme } = useTheme(); + const wrapperRef = useRef(null); + const onChangeRef = useRef(onChange); + const onSelectionChangeRef = useRef(onSelectionChange); + const onSelectionEndRef = useRef(onSelectionEnd); + const onCommentClickRef = useRef(onCommentClick); + const [isReady, setIsReady] = useState(false); + + // Keep refs updated + useEffect(() => { onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]); + useEffect(() => { onSelectionEndRef.current = onSelectionEnd; }, [onSelectionEnd]); + useEffect(() => { onCommentClickRef.current = onCommentClick; }, [onCommentClick]); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + codeBlock: false, // We use CodeBlockLowlight instead + }), + CodeBlockLowlight.configure({ lowlight }), + Markdown.configure({ + html: true, + transformPastedText: true, + transformCopiedText: true, + }), + Placeholder.configure({ placeholder }), + Link.configure({ openOnClick: false }), + TaskList, + TaskItem.configure({ nested: true }), + Table.configure({ resizable: false }), + TableRow, + TableCell, + TableHeader, + CommentHighlightExtension.configure({ comments }), + ], + content: value, + editorProps: { + attributes: { + class: 'tiptap-plan-editor', + }, + }, + onUpdate: ({ editor: ed }) => { + // tiptap-markdown adds getMarkdown() to the editor storage + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const md = (ed.storage as any).markdown?.getMarkdown?.() as string | undefined; + onChangeRef.current(md ?? ed.getText()); + }, + onCreate: () => { + setIsReady(true); + }, + }); + + // Update comment decorations when comments change + useEffect(() => { + if (!editor || !isReady) return; + try { + updateCommentHighlights(editor as Parameters[0], comments); + } catch { + // Editor might not be ready yet + } + }, [comments, editor, isReady]); + + // Set up click handler for comment highlights via event delegation + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const commentHighlight = target.closest('.comment-highlight'); + if (commentHighlight && onCommentClickRef.current) { + const commentId = commentHighlight.getAttribute('data-comment-id'); + if (commentId) { + onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY }); + } + } + }; + + wrapper.addEventListener('click', handleClick); + return () => wrapper.removeEventListener('click', handleClick); + }, []); + + // Handle mouseup to show floating button when selection ends + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + const handleMouseUp = (e: MouseEvent) => { + if (!onSelectionEndRef.current) return; + const mouseX = e.clientX; + const mouseY = e.clientY; + + setTimeout(() => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || !selection.toString().trim()) { + onSelectionEndRef.current?.(null); + return; + } + const selectedText = selection.toString().trim(); + if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) { + onSelectionEndRef.current?.(null); + return; + } + onSelectionEndRef.current?.({ + text: selectedText, + position: { x: mouseX, y: mouseY }, + }); + }, 10); + }; + + const handleMouseDown = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('.floating-comment-btn')) return; + onSelectionEndRef.current?.(null); + }; + + wrapper.addEventListener('mouseup', handleMouseUp); + wrapper.addEventListener('mousedown', handleMouseDown); + return () => { + wrapper.removeEventListener('mouseup', handleMouseUp); + wrapper.removeEventListener('mousedown', handleMouseDown); + }; + }, []); + + // Handle Cmd+I to trigger selection popover + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'i') { + if (!onSelectionChangeRef.current) return; + const selection = window.getSelection(); + if (!selection) return; + + e.preventDefault(); + e.stopImmediatePropagation(); + + if (selection.isCollapsed || !selection.toString().trim()) return; + const selectedText = selection.toString().trim(); + if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) return; + + const focusNode = selection.focusNode; + const focusOffset = selection.focusOffset; + + if (focusNode) { + const focusRange = document.createRange(); + focusRange.setStart(focusNode, focusOffset); + focusRange.setEnd(focusNode, focusOffset); + const focusRect = focusRange.getBoundingClientRect(); + + let x = focusRect.right || focusRect.left; + let y = focusRect.bottom || focusRect.top; + + if (x === 0 && y === 0) { + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect(); + x = lastRect.right; + y = lastRect.bottom; + } + + onSelectionChangeRef.current({ + text: selectedText, + position: { x, y }, + }); + } + } + }; + + wrapper.addEventListener('keydown', handleKeyDown, true); + return () => wrapper.removeEventListener('keydown', handleKeyDown, true); + }, []); + + return ( +
+ + {!isReady && ( +
+ Loading editor... +
+ )} +
+ ); +} diff --git a/apps/web/components/review/review-dialog.tsx b/apps/web/components/review/review-dialog.tsx index 99ad30705c..f771113f98 100644 --- a/apps/web/components/review/review-dialog.tsx +++ b/apps/web/components/review/review-dialog.tsx @@ -22,6 +22,7 @@ type ReviewDialogProps = { baseBranch?: string; taskTitle?: string; onSendComments: (comments: DiffComment[]) => void; + onOpenFile?: (filePath: string) => void; gitStatusFiles: Record | null; cumulativeDiff: CumulativeDiff | null; }; @@ -34,6 +35,7 @@ export const ReviewDialog = memo(function ReviewDialog({ baseBranch, taskTitle, onSendComments, + onOpenFile, gitStatusFiles, cumulativeDiff, }: ReviewDialogProps) { @@ -257,6 +259,7 @@ export const ReviewDialog = memo(function ReviewDialog({ wordWrap={wordWrap} onToggleReviewed={handleToggleReviewed} onDiscard={handleDiscard} + onOpenFile={onOpenFile} fileRefs={fileRefs} /> ) : ( diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index 48fe1fee15..e1b9b96a82 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -3,14 +3,14 @@ import { memo, useEffect, useRef, useState, useCallback } from 'react'; import { IconAlertTriangle, - IconCopy, IconArrowBackUp, + IconPencil, } from '@tabler/icons-react'; import { Checkbox } from '@kandev/ui/checkbox'; import { Button } from '@kandev/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { FileDiffViewer } from '@/components/diff'; -import { useToast } from '@/components/toast-provider'; +import { FileActionsDropdown } from '@/components/editors/file-actions-dropdown'; import type { ReviewFile } from './types'; type ReviewDiffListProps = { @@ -22,6 +22,7 @@ type ReviewDiffListProps = { wordWrap: boolean; onToggleReviewed: (path: string, reviewed: boolean) => void; onDiscard: (path: string) => void; + onOpenFile?: (filePath: string) => void; fileRefs: Map>; }; @@ -34,6 +35,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({ wordWrap, onToggleReviewed, onDiscard, + onOpenFile, fileRefs, }: ReviewDiffListProps) { const scrollContainerRef = useRef(null); @@ -51,6 +53,7 @@ export const ReviewDiffList = memo(function ReviewDiffList({ wordWrap={wordWrap} onToggleReviewed={onToggleReviewed} onDiscard={onDiscard} + onOpenFile={onOpenFile} sectionRef={fileRefs.get(file.path)} scrollContainer={scrollContainerRef} /> @@ -68,6 +71,7 @@ type FileDiffSectionProps = { wordWrap: boolean; onToggleReviewed: (path: string, reviewed: boolean) => void; onDiscard: (path: string) => void; + onOpenFile?: (filePath: string) => void; sectionRef?: React.RefObject; scrollContainer: React.RefObject; }; @@ -81,13 +85,13 @@ function FileDiffSection({ wordWrap, onToggleReviewed, onDiscard, + onOpenFile, sectionRef, scrollContainer, }: FileDiffSectionProps) { const [isVisible, setIsVisible] = useState(false); const sentinelRef = useRef(null); const autoMarkedRef = useRef(false); - const { toast } = useToast(); // Lazy rendering: mount the diff viewer only when section becomes visible useEffect(() => { @@ -144,11 +148,6 @@ function FileDiffSection({ [file.path, onToggleReviewed] ); - const handleCopyPath = useCallback(() => { - navigator.clipboard.writeText(file.path); - toast({ title: 'Copied', description: file.path, variant: 'success' }); - }, [file.path, toast]); - const handleDiscard = useCallback(() => { onDiscard(file.path); }, [file.path, onDiscard]); @@ -184,20 +183,25 @@ function FileDiffSection({ {/* File actions */}
- {/* Copy path */} - - - - - Copy file path - + {/* Edit in editor tab */} + {onOpenFile && ( + + + + + Edit + + )} + + {/* Open with external editor / copy path / open folder */} + {/* Discard changes */} {file.source === 'uncommitted' && ( diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index 2303c39117..f086382260 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -1,11 +1,16 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { IconEdit, IconTrash } from '@tabler/icons-react'; +import { IconEdit, IconTrash, IconChevronDown, IconExternalLink, IconInfoCircle } from '@tabler/icons-react'; import { Badge } from '@kandev/ui/badge'; import { Button } from '@kandev/ui/button'; +import { Checkbox } from '@kandev/ui/checkbox'; import { Input } from '@kandev/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; +import { Separator } from '@kandev/ui/separator'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { Switch } from '@kandev/ui/switch'; +import { Textarea } from '@kandev/ui/textarea'; import { SettingsPageTemplate } from '@/components/settings/settings-page-template'; import { Combobox, type ComboboxOption } from '@/components/combobox'; import { useAppStore } from '@/components/state-provider'; @@ -13,6 +18,7 @@ import { EditableCard } from '@/components/settings/editable-card'; import { useEditors } from '@/hooks/domains/settings/use-editors'; import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/api'; import { useRequest } from '@/lib/http/use-request'; +import { LSP_DEFAULT_CONFIGS } from '@/lib/lsp/lsp-client-manager'; import type { EditorOption } from '@/lib/types/http'; type CustomKind = 'custom_command' | 'custom_remote_ssh' | 'custom_hosted_url'; @@ -36,6 +42,21 @@ const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; label: string }> = [ const PLACEHOLDER_HINT = '{cwd} {file} {rel} {line} {column}'; +const LSP_LANGUAGE_OPTIONS = [ + { id: 'typescript', label: 'TypeScript / JavaScript', binary: 'typescript-language-server', + docsUrl: 'https://github.com/typescript-language-server/typescript-language-server#workspace-configuration', + installHint: 'Installs typescript-language-server and typescript via npm into ~/.kandev/lsp-servers/' }, + { id: 'go', label: 'Go', binary: 'gopls', + docsUrl: 'https://github.com/golang/tools/blob/master/gopls/doc/settings.md', + installHint: 'Runs "go install golang.org/x/tools/gopls@latest". Requires Go to be installed.' }, + { id: 'rust', label: 'Rust', binary: 'rust-analyzer', + docsUrl: 'https://rust-analyzer.github.io/book/configuration.html', + installHint: 'Downloads the rust-analyzer binary from GitHub releases into ~/.kandev/lsp-servers/' }, + { id: 'python', label: 'Python', binary: 'pyright-langserver', + docsUrl: 'https://microsoft.github.io/pyright/#/settings', + installHint: 'Installs pyright via npm into ~/.kandev/lsp-servers/' }, +]; + function getCustomKindLabel(kind: string) { return CUSTOM_KIND_OPTIONS.find((option) => option.value === kind)?.label ?? 'Custom'; } @@ -288,6 +309,98 @@ export function EditorsSettings() { const [isAdding, setIsAdding] = useState(false); const [editingId, setEditingId] = useState(null); + // LSP settings + const [lspAutoStartLanguages, setLspAutoStartLanguages] = useState( + () => currentUserSettings.lspAutoStartLanguages ?? [] + ); + const [lspAutoInstallLanguages, setLspAutoInstallLanguages] = useState( + () => currentUserSettings.lspAutoInstallLanguages ?? [] + ); + const [baselineLspAutoStart, setBaselineLspAutoStart] = useState( + () => currentUserSettings.lspAutoStartLanguages ?? [] + ); + const [baselineLspAutoInstall, setBaselineLspAutoInstall] = useState( + () => currentUserSettings.lspAutoInstallLanguages ?? [] + ); + + // LSP server configs (per-language JSON overrides) + const initConfigStrings = useCallback((): Record => { + const configs = currentUserSettings.lspServerConfigs ?? {}; + const result: Record = {}; + for (const [lang, config] of Object.entries(configs)) { + if (config && Object.keys(config).length > 0) { + result[lang] = JSON.stringify(config, null, 2); + } + } + return result; + }, [currentUserSettings.lspServerConfigs]); + const [lspConfigStrings, setLspConfigStrings] = useState>(initConfigStrings); + const [baselineLspConfigStrings, setBaselineLspConfigStrings] = useState>(initConfigStrings); + const [expandedConfigLang, setExpandedConfigLang] = useState(null); + const [lspConfigErrors, setLspConfigErrors] = useState>({}); + + const toggleAutoStart = useCallback((langId: string, checked: boolean) => { + setLspAutoStartLanguages((prev) => + checked ? [...prev, langId] : prev.filter((id) => id !== langId) + ); + }, []); + + const toggleAutoInstall = useCallback((langId: string, checked: boolean) => { + setLspAutoInstallLanguages((prev) => + checked ? [...prev, langId] : prev.filter((id) => id !== langId) + ); + }, []); + + const updateLspConfigString = useCallback((langId: string, value: string) => { + setLspConfigStrings((prev) => { + if (!value.trim()) { + const next = { ...prev }; + delete next[langId]; + return next; + } + return { ...prev, [langId]: value }; + }); + // Validate JSON + if (!value.trim()) { + setLspConfigErrors((prev) => { + const next = { ...prev }; + delete next[langId]; + return next; + }); + return; + } + try { + const parsed = JSON.parse(value); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Must be a JSON object' })); + } else { + setLspConfigErrors((prev) => { + const next = { ...prev }; + delete next[langId]; + return next; + }); + } + } catch { + setLspConfigErrors((prev) => ({ ...prev, [langId]: 'Invalid JSON' })); + } + }, []); + + const parseLspConfigs = useCallback((): Record> | null => { + const result: Record> = {}; + for (const [lang, str] of Object.entries(lspConfigStrings)) { + if (!str.trim()) continue; + try { + const parsed = JSON.parse(str); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + result[lang] = parsed as Record; + } catch { + return null; + } + } + return result; + }, [lspConfigStrings]); + + const customEditors = useMemo(() => { const items = editors.filter(isCustomEditor); return items.slice().sort((a, b) => { @@ -350,17 +463,32 @@ export function EditorsSettings() { })); }, [availableEditors, defaultEditorId]); - const isDirty = defaultEditorId !== baselineDefaultId; + const lspAutoStartDirty = + lspAutoStartLanguages.length !== baselineLspAutoStart.length || + lspAutoStartLanguages.some((id) => !baselineLspAutoStart.includes(id)); + const lspAutoInstallDirty = + lspAutoInstallLanguages.length !== baselineLspAutoInstall.length || + lspAutoInstallLanguages.some((id) => !baselineLspAutoInstall.includes(id)); + const lspConfigsDirty = JSON.stringify(lspConfigStrings) !== JSON.stringify(baselineLspConfigStrings); + const isDirty = defaultEditorId !== baselineDefaultId || lspAutoStartDirty || lspAutoInstallDirty || lspConfigsDirty; const saveDefaultRequest = useRequest(async () => { - const payload = { + const parsedConfigs = parseLspConfigs(); + if (parsedConfigs === null) return; // Invalid JSON, don't save + const payload: Parameters[0] = { workspace_id: currentUserSettings.workspaceId ?? '', board_id: currentUserSettings.boardId ?? '', repository_ids: currentUserSettings.repositoryIds ?? [], default_editor_id: defaultEditorId || undefined, + lsp_auto_start_languages: lspAutoStartLanguages, + lsp_auto_install_languages: lspAutoInstallLanguages, + lsp_server_configs: parsedConfigs, }; const response = await updateUserSettings(payload, { cache: 'no-store' }); setBaselineDefaultId(defaultEditorId); + setBaselineLspAutoStart([...lspAutoStartLanguages]); + setBaselineLspAutoInstall([...lspAutoInstallLanguages]); + setBaselineLspConfigStrings({ ...lspConfigStrings }); if (response?.settings) { setUserSettings({ workspaceId: response.settings.workspace_id || null, @@ -372,6 +500,9 @@ export function EditorsSettings() { enablePreviewOnClick: response.settings.enable_preview_on_click ?? false, chatSubmitKey: response.settings.chat_submit_key ?? 'cmd_enter', reviewAutoMarkOnScroll: response.settings.review_auto_mark_on_scroll ?? true, + lspAutoStartLanguages: response.settings.lsp_auto_start_languages ?? [], + lspAutoInstallLanguages: response.settings.lsp_auto_install_languages ?? [], + lspServerConfigs: response.settings.lsp_server_configs ?? {}, loaded: true, }); } @@ -426,17 +557,141 @@ export function EditorsSettings() { return ( { void saveDefaultRequest.run(); }} > -
-
-
Default editor
-
+
+ {/* ── File Editor ────────────────────────────────────── */} +
+
+ File Editor +
+ +
+
+
Language Servers
+
+ Auto-start language servers when opening files to get diagnostics, hover info, and go-to-definition. You can also toggle each server on/off per file. +
+ When enabled, install your project's dependencies (e.g. npm install via repository setup scripts) to avoid missing type errors. +
+
+
+ {LSP_LANGUAGE_OPTIONS.map((lang) => ( +
+
+
{lang.label}
+
{lang.binary}
+
+
+ Auto-start + toggleAutoStart(lang.id, checked === true)} + /> +
+
+ toggleAutoInstall(lang.id, checked === true)} + className="h-3.5 w-3.5" + /> + + + + + + + {lang.installHint} + + +
+
+ ))} +
+
+ +
+
+
Server Configuration
+
+ Override settings sent to each language server via workspace/configuration. JSON format. +
+
+ {LSP_LANGUAGE_OPTIONS.map((lang) => { + const isExpanded = expandedConfigLang === lang.id; + const configStr = lspConfigStrings[lang.id] ?? ''; + const defaultConfig = LSP_DEFAULT_CONFIGS[lang.id]; + const hasDefaults = defaultConfig && Object.keys(defaultConfig).length > 0; + const error = lspConfigErrors[lang.id]; + return ( +
+ + {isExpanded && ( +
+ {hasDefaults && ( +
+ Defaults: {JSON.stringify(defaultConfig)} +
+ )} + + View available settings + + +