From 9adf40e0cca87c21135bde2a01c7f01de5cd677a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 19 Jan 2026 00:01:14 +0000 Subject: [PATCH] feat: multiple editors support (#41) --- apps/backend/cmd/kandev/main.go | 17 + .../internal/editors/controller/controller.go | 102 ++++ .../internal/editors/discovery/discovery.go | 34 ++ .../internal/editors/discovery/editors.json | 53 ++ apps/backend/internal/editors/dto/dto.go | 66 +++ .../internal/editors/handlers/handlers.go | 98 +++ .../backend/internal/editors/models/models.go | 20 + .../internal/editors/service/service.go | 483 +++++++++++++++ .../internal/editors/store/interface.go | 18 + apps/backend/internal/editors/store/sqlite.go | 428 ++++++++++++++ .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + .../internal/user/handlers/handlers.go | 4 + apps/backend/internal/user/models/models.go | 1 + apps/backend/internal/user/service/service.go | 25 + apps/backend/internal/user/store/sqlite.go | 3 + apps/backend/pkg/api/v1/user.go | 13 +- apps/web/app/page.tsx | 1 + .../web/app/settings/general/editors/page.tsx | 21 + apps/web/app/settings/general/page.tsx | 53 +- apps/web/app/settings/layout.tsx | 13 +- .../components/settings/editors-settings.tsx | 559 ++++++++++++++++++ .../settings/settings-app-sidebar.tsx | 13 + .../messages/permission-request-message.tsx | 2 +- .../task/chat/messages/tool-call-message.tsx | 1 - apps/web/components/task/editors-menu.tsx | 97 +++ apps/web/components/task/task-files-panel.tsx | 4 + .../components/task/task-session-sidebar.tsx | 11 +- apps/web/components/task/task-top-bar.tsx | 53 +- apps/web/hooks/use-editors.ts | 66 +++ apps/web/hooks/use-open-session-in-editor.ts | 41 ++ apps/web/hooks/use-user-display-settings.ts | 3 + apps/web/lib/http/index.ts | 56 ++ apps/web/lib/state/store.ts | 28 + apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http.ts | 17 + apps/web/lib/ws/handlers/users.ts | 1 + 37 files changed, 2290 insertions(+), 120 deletions(-) create mode 100644 apps/backend/internal/editors/controller/controller.go create mode 100644 apps/backend/internal/editors/discovery/discovery.go create mode 100644 apps/backend/internal/editors/discovery/editors.json create mode 100644 apps/backend/internal/editors/dto/dto.go create mode 100644 apps/backend/internal/editors/handlers/handlers.go create mode 100644 apps/backend/internal/editors/models/models.go create mode 100644 apps/backend/internal/editors/service/service.go create mode 100644 apps/backend/internal/editors/store/interface.go create mode 100644 apps/backend/internal/editors/store/sqlite.go create mode 100644 apps/web/app/settings/general/editors/page.tsx create mode 100644 apps/web/components/settings/editors-settings.tsx create mode 100644 apps/web/components/task/editors-menu.tsx create mode 100644 apps/web/hooks/use-editors.ts create mode 100644 apps/web/hooks/use-open-session-in-editor.ts diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 4d1ff488c7..dd565ee50a 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -45,6 +45,10 @@ import ( notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" notificationservice "github.com/kandev/kandev/internal/notifications/service" notificationstore "github.com/kandev/kandev/internal/notifications/store" + editorcontroller "github.com/kandev/kandev/internal/editors/controller" + editorhandlers "github.com/kandev/kandev/internal/editors/handlers" + editorservice "github.com/kandev/kandev/internal/editors/service" + editorstore "github.com/kandev/kandev/internal/editors/store" // Orchestrator packages "github.com/kandev/kandev/internal/orchestrator" @@ -221,6 +225,14 @@ func main() { _ = notificationRepo.Close() }() + editorRepo, err := editorstore.NewSQLiteRepository(dbPath) + if err != nil { + log.Fatal("Failed to initialize editor store", zap.Error(err), zap.String("db_path", dbPath)) + } + defer func() { + _ = editorRepo.Close() + }() + discoveryRegistry, err := discovery.LoadRegistry() if err != nil { log.Fatal("Failed to load agent discovery config", zap.Error(err)) @@ -231,6 +243,8 @@ func main() { userCtrl := usercontroller.NewController(userSvc) var notificationSvc *notificationservice.Service var notificationCtrl *notificationcontroller.Controller + editorSvc := editorservice.NewService(editorRepo, taskRepo, userSvc) + editorCtrl := editorcontroller.NewController(editorSvc) taskSvc := taskservice.NewService( taskRepo, @@ -800,6 +814,9 @@ func main() { notificationhandlers.RegisterRoutes(router, notificationCtrl, log) log.Info("Registered Notification handlers (HTTP)") + editorhandlers.RegisterRoutes(router, editorCtrl, log) + log.Info("Registered Editors handlers (HTTP)") + // Health check (simple HTTP for load balancers/monitoring) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/apps/backend/internal/editors/controller/controller.go b/apps/backend/internal/editors/controller/controller.go new file mode 100644 index 0000000000..8fa6376956 --- /dev/null +++ b/apps/backend/internal/editors/controller/controller.go @@ -0,0 +1,102 @@ +package controller + +import ( + "context" + "sort" + + "github.com/kandev/kandev/internal/editors/discovery" + "github.com/kandev/kandev/internal/editors/dto" + "github.com/kandev/kandev/internal/editors/service" +) + +type Controller struct { + service *service.Service +} + +func NewController(svc *service.Service) *Controller { + return &Controller{service: svc} +} + +func (c *Controller) ListEditors(ctx context.Context) (dto.EditorsResponse, error) { + editors, err := c.service.ListEditors(ctx) + if err != nil { + return dto.EditorsResponse{}, err + } + result := make([]dto.EditorDTO, 0, len(editors)) + for _, editor := range editors { + if editor == nil || !editor.Enabled { + continue + } + result = append(result, dto.FromEditor(editor)) + } + if order, err := discovery.LoadDefaults(); err == nil { + orderIndex := make(map[string]int, len(order)) + for index, editor := range order { + if editor.Type == "" { + continue + } + orderIndex[editor.Type] = index + } + sort.SliceStable(result, func(i, j int) bool { + left, leftOk := orderIndex[result[i].Type] + right, rightOk := orderIndex[result[j].Type] + if leftOk && rightOk { + return left < right + } + if leftOk { + return true + } + if rightOk { + return false + } + return result[i].Name < result[j].Name + }) + } + return dto.EditorsResponse{Editors: result}, nil +} + +func (c *Controller) OpenSessionEditor(ctx context.Context, sessionID string, req dto.OpenEditorRequest) (dto.OpenEditorResponse, error) { + url, err := c.service.OpenEditor(ctx, service.OpenEditorInput{ + SessionID: sessionID, + EditorID: req.EditorID, + EditorType: req.EditorType, + FilePath: req.FilePath, + Line: req.Line, + Column: req.Column, + }) + if err != nil { + return dto.OpenEditorResponse{}, err + } + return dto.OpenEditorResponse{URL: url}, nil +} + +func (c *Controller) CreateEditor(ctx context.Context, req dto.CreateEditorRequest) (dto.EditorDTO, error) { + editor, err := c.service.CreateEditor(ctx, service.CreateEditorInput{ + Name: req.Name, + Kind: req.Kind, + Config: req.Config, + Enabled: req.Enabled, + }) + if err != nil { + return dto.EditorDTO{}, err + } + return dto.FromEditor(editor), nil +} + +func (c *Controller) UpdateEditor(ctx context.Context, editorID string, req dto.UpdateEditorRequest) (dto.EditorDTO, error) { + editor, err := c.service.UpdateEditor(ctx, service.UpdateEditorInput{ + EditorID: editorID, + Name: req.Name, + Kind: req.Kind, + Config: req.Config, + Enabled: req.Enabled, + }) + if err != nil { + return dto.EditorDTO{}, err + } + return dto.FromEditor(editor), nil +} + +func (c *Controller) DeleteEditor(ctx context.Context, editorID string) error { + return c.service.DeleteEditor(ctx, editorID) +} diff --git a/apps/backend/internal/editors/discovery/discovery.go b/apps/backend/internal/editors/discovery/discovery.go new file mode 100644 index 0000000000..a3f0715494 --- /dev/null +++ b/apps/backend/internal/editors/discovery/discovery.go @@ -0,0 +1,34 @@ +package discovery + +import ( + "embed" + "encoding/json" + "fmt" +) + +//go:embed editors.json +var editorsFS embed.FS + +type EditorDefinition struct { + Type string `json:"type"` + Name string `json:"name"` + Command string `json:"command"` + Scheme string `json:"scheme"` + Enabled bool `json:"enabled"` +} + +type Config struct { + Editors []EditorDefinition `json:"editors"` +} + +func LoadDefaults() ([]EditorDefinition, error) { + data, err := editorsFS.ReadFile("editors.json") + if err != nil { + return nil, fmt.Errorf("read editors config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse editors config: %w", err) + } + return cfg.Editors, nil +} diff --git a/apps/backend/internal/editors/discovery/editors.json b/apps/backend/internal/editors/discovery/editors.json new file mode 100644 index 0000000000..7d1399dfe9 --- /dev/null +++ b/apps/backend/internal/editors/discovery/editors.json @@ -0,0 +1,53 @@ +{ + "editors": [ + { + "type": "vscode", + "name": "VS Code", + "command": "code", + "scheme": "vscode", + "enabled": true + }, + { + "type": "zed", + "name": "Zed", + "command": "zed", + "scheme": "zed", + "enabled": true + }, + { + "type": "cursor", + "name": "Cursor", + "command": "cursor", + "scheme": "cursor", + "enabled": true + }, + { + "type": "windsurf", + "name": "Windsurf", + "command": "windsurf", + "scheme": "windsurf", + "enabled": true + }, + { + "type": "intellij", + "name": "IntelliJ IDEA", + "command": "idea", + "scheme": "idea", + "enabled": true + }, + { + "type": "golang", + "name": "GoLand", + "command": "goland", + "scheme": "goland", + "enabled": true + }, + { + "type": "phpstorm", + "name": "PhpStorm", + "command": "phpstorm", + "scheme": "phpstorm", + "enabled": true + } + ] +} diff --git a/apps/backend/internal/editors/dto/dto.go b/apps/backend/internal/editors/dto/dto.go new file mode 100644 index 0000000000..511c787e5a --- /dev/null +++ b/apps/backend/internal/editors/dto/dto.go @@ -0,0 +1,66 @@ +package dto + +import ( + "encoding/json" + + "github.com/kandev/kandev/internal/editors/models" +) + +type EditorDTO struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Kind string `json:"kind"` + Command string `json:"command,omitempty"` + Scheme string `json:"scheme,omitempty"` + Config json.RawMessage `json:"config,omitempty"` + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` +} + +type EditorsResponse struct { + Editors []EditorDTO `json:"editors"` +} + +type OpenEditorRequest struct { + EditorID string `json:"editor_id,omitempty"` + EditorType string `json:"editor_type,omitempty"` + FilePath string `json:"file_path,omitempty"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` +} + +type OpenEditorResponse struct { + URL string `json:"url,omitempty"` +} + +func FromEditor(editor *models.Editor) EditorDTO { + if editor == nil { + return EditorDTO{} + } + return EditorDTO{ + ID: editor.ID, + Type: editor.Type, + Name: editor.Name, + Kind: editor.Kind, + Command: editor.Command, + Scheme: editor.Scheme, + Config: editor.Config, + Installed: editor.Installed, + Enabled: editor.Enabled, + } +} + +type CreateEditorRequest struct { + Name string `json:"name"` + Kind string `json:"kind"` + Config json.RawMessage `json:"config,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +type UpdateEditorRequest struct { + Name *string `json:"name,omitempty"` + Kind *string `json:"kind,omitempty"` + Config json.RawMessage `json:"config,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} diff --git a/apps/backend/internal/editors/handlers/handlers.go b/apps/backend/internal/editors/handlers/handlers.go new file mode 100644 index 0000000000..de97bdd8d5 --- /dev/null +++ b/apps/backend/internal/editors/handlers/handlers.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/editors/controller" + "github.com/kandev/kandev/internal/editors/dto" +) + +type Handlers struct { + controller *controller.Controller + logger *logger.Logger +} + +func NewHandlers(ctrl *controller.Controller, log *logger.Logger) *Handlers { + return &Handlers{ + controller: ctrl, + logger: log.WithFields(zap.String("component", "editors-handlers")), + } +} + +func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger.Logger) { + handlers := NewHandlers(ctrl, log) + api := router.Group("/api/v1") + api.GET("/editors", handlers.httpListEditors) + api.POST("/editors", handlers.httpCreateEditor) + api.PATCH("/editors/:id", handlers.httpUpdateEditor) + api.DELETE("/editors/:id", handlers.httpDeleteEditor) + api.POST("/task-sessions/:id/open-editor", handlers.httpOpenSessionEditor) +} + +func (h *Handlers) httpListEditors(c *gin.Context) { + resp, err := h.controller.ListEditors(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list editors", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list editors"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpOpenSessionEditor(c *gin.Context) { + var req dto.OpenEditorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.OpenSessionEditor(c.Request.Context(), c.Param("id"), req) + if err != nil { + h.logger.Error("failed to open editor", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open editor"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpCreateEditor(c *gin.Context) { + var req dto.CreateEditorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.CreateEditor(c.Request.Context(), req) + if err != nil { + h.logger.Error("failed to create editor", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create editor"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpUpdateEditor(c *gin.Context) { + var req dto.UpdateEditorRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.UpdateEditor(c.Request.Context(), c.Param("id"), req) + if err != nil { + h.logger.Error("failed to update editor", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update editor"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpDeleteEditor(c *gin.Context) { + if err := h.controller.DeleteEditor(c.Request.Context(), c.Param("id")); err != nil { + h.logger.Error("failed to delete editor", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete editor"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/apps/backend/internal/editors/models/models.go b/apps/backend/internal/editors/models/models.go new file mode 100644 index 0000000000..fa11d5ced7 --- /dev/null +++ b/apps/backend/internal/editors/models/models.go @@ -0,0 +1,20 @@ +package models + +import ( + "encoding/json" + "time" +) + +type Editor struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Kind string `json:"kind"` + Command string `json:"command"` + Scheme string `json:"scheme"` + Config json.RawMessage `json:"config"` + Installed bool `json:"installed"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go new file mode 100644 index 0000000000..c535900f83 --- /dev/null +++ b/apps/backend/internal/editors/service/service.go @@ -0,0 +1,483 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/editors/models" + "github.com/kandev/kandev/internal/editors/store" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + usermodels "github.com/kandev/kandev/internal/user/models" +) + +var ( + ErrEditorNotFound = errors.New("editor not found") + ErrEditorUnavailable = errors.New("editor not available") + ErrEditorConfigInvalid = errors.New("editor configuration invalid") + ErrWorkspaceNotFound = errors.New("workspace path not found") +) + +type UserSettingsProvider interface { + GetUserSettings(ctx context.Context) (*usermodels.UserSettings, error) + ClearDefaultEditorID(ctx context.Context, editorID string) error +} + +type Service struct { + repo store.Repository + taskRepo repository.Repository + userSettings UserSettingsProvider +} + +func NewService(repo store.Repository, taskRepo repository.Repository, userSettings UserSettingsProvider) *Service { + return &Service{ + repo: repo, + taskRepo: taskRepo, + userSettings: userSettings, + } +} + +func (s *Service) ListEditors(ctx context.Context) ([]*models.Editor, error) { + return s.repo.ListEditors(ctx) +} + +type OpenEditorInput struct { + SessionID string + EditorID string + EditorType string + FilePath string + Line int + Column int +} + +func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string, error) { + if input.SessionID == "" { + return "", ErrEditorConfigInvalid + } + session, err := s.taskRepo.GetTaskSession(ctx, input.SessionID) + if err != nil { + return "", err + } + worktreePath, err := s.resolveSessionPath(ctx, session) + if err != nil { + return "", err + } + + settings, err := s.userSettings.GetUserSettings(ctx) + if err != nil { + return "", err + } + if settings == nil { + return "", ErrEditorConfigInvalid + } + + editor, err := s.resolveEditor(ctx, input.EditorID, input.EditorType, settings.DefaultEditorID) + if err != nil { + return "", err + } + + absPath, err := s.resolveFilePath(worktreePath, input.FilePath) + if err != nil { + return "", err + } + + switch editor.Kind { + case "custom_remote_ssh": + cfg, err := parseRemoteSSHConfig(editor.Config) + if err != nil { + return "", err + } + scheme := cfg.Scheme + if scheme == "" { + scheme = editor.Scheme + } + return buildRemoteSSHURL(scheme, cfg.User, cfg.Host, absPath, input.Line, input.Column), nil + case "custom_hosted_url": + cfg, err := parseHostedURLConfig(editor.Config) + if err != nil { + return "", err + } + return buildHostedURL(cfg.URL, absPath, input.Line, input.Column) + case "custom_command": + cfg, err := parseCommandConfig(editor.Config) + if err != nil { + return "", err + } + return launchCommand(cfg.Command, worktreePath, absPath, input.Line, input.Column) + default: + if !editor.Installed { + return "", ErrEditorUnavailable + } + if editor.Command == "" { + return "", ErrEditorConfigInvalid + } + args := buildLocalArgs(editor.Type, absPath, input.Line, input.Column) + cmd := exec.Command(editor.Command, args...) + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("launch failed: %w", err) + } + return "", nil + } +} + +func (s *Service) resolveEditor(ctx context.Context, editorID, editorType, fallbackID string) (*models.Editor, error) { + if editorID == "" { + editorID = strings.TrimSpace(fallbackID) + } + if editorID != "" { + editor, err := s.repo.GetEditorByID(ctx, editorID) + if err != nil || editor == nil { + return nil, ErrEditorNotFound + } + if !editor.Enabled { + return nil, ErrEditorUnavailable + } + return editor, nil + } + if editorType == "" { + editors, err := s.repo.ListEditors(ctx) + if err != nil { + return nil, err + } + for _, editor := range editors { + if editor.Enabled { + return editor, nil + } + } + return nil, ErrEditorNotFound + } + editor, err := s.repo.GetEditorByType(ctx, editorType) + if err != nil || editor == nil { + return nil, ErrEditorNotFound + } + if !editor.Enabled { + return nil, ErrEditorUnavailable + } + return editor, nil +} + +func (s *Service) resolveSessionPath(ctx context.Context, session *taskmodels.TaskSession) (string, error) { + if session == nil { + return "", ErrWorkspaceNotFound + } + if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" { + return session.Worktrees[0].WorktreePath, nil + } + if session.RepositoryID != "" { + repo, err := s.taskRepo.GetRepository(ctx, session.RepositoryID) + if err != nil { + return "", err + } + if repo != nil && repo.LocalPath != "" { + return repo.LocalPath, nil + } + } + return "", ErrWorkspaceNotFound +} + +func (s *Service) resolveFilePath(worktreePath, filePath string) (string, error) { + if worktreePath == "" { + return "", ErrWorkspaceNotFound + } + if filePath == "" { + return worktreePath, nil + } + clean := filepath.Clean(filePath) + abs := filepath.Join(worktreePath, clean) + rel, err := filepath.Rel(worktreePath, abs) + if err != nil { + return "", ErrEditorConfigInvalid + } + if strings.HasPrefix(rel, "..") { + return "", ErrEditorConfigInvalid + } + return abs, nil +} + +func buildRemoteSSHURL(scheme, user, host, path string, line, column int) string { + if scheme == "" { + scheme = "vscode" + } + remoteHost := host + if user != "" { + remoteHost = fmt.Sprintf("%s@%s", user, host) + } + location := path + if line > 0 { + location = fmt.Sprintf("%s:%d", path, line) + if column > 0 { + location = fmt.Sprintf("%s:%d", location, column) + } + } + return fmt.Sprintf("%s://vscode-remote/ssh-remote+%s:%s", scheme, remoteHost, location) +} + +func buildHostedURL(baseURL, path string, line, column int) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return "", ErrEditorConfigInvalid + } + query := parsed.Query() + if path != "" { + location := path + if line > 0 { + location = fmt.Sprintf("%s:%d", path, line) + if column > 0 { + location = fmt.Sprintf("%s:%d", location, column) + } + } + query.Set("file", location) + } else { + query.Set("folder", "/") + } + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func buildLocalArgs(editorType, path string, line, column int) []string { + if path == "" { + return nil + } + normalized := strings.TrimSpace(editorType) + switch normalized { + case "vscode", "cursor", "windsurf": + if line > 0 { + location := path + ":" + strconv.Itoa(line) + if column > 0 { + location += ":" + strconv.Itoa(column) + } + return []string{"--goto", location} + } + return []string{path} + default: + return []string{path} + } +} + +type CreateEditorInput struct { + Name string + Kind string + Config json.RawMessage + Enabled *bool +} + +type UpdateEditorInput struct { + EditorID string + Name *string + Kind *string + Config json.RawMessage + Enabled *bool +} + +func (s *Service) CreateEditor(ctx context.Context, input CreateEditorInput) (*models.Editor, error) { + name := strings.TrimSpace(input.Name) + if name == "" { + return nil, ErrEditorConfigInvalid + } + if !isCustomKind(input.Kind) { + return nil, ErrEditorConfigInvalid + } + editorID := uuid.New().String() + editor := &models.Editor{ + ID: editorID, + Type: "custom:" + editorID, + Name: name, + Kind: input.Kind, + Command: "", + Scheme: "", + Config: input.Config, + Installed: true, + Enabled: true, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + if input.Enabled != nil { + editor.Enabled = *input.Enabled + } + if err := validateEditorConfig(editor); err != nil { + return nil, err + } + if err := s.repo.CreateEditor(ctx, editor); err != nil { + return nil, err + } + return editor, nil +} + +func (s *Service) UpdateEditor(ctx context.Context, input UpdateEditorInput) (*models.Editor, error) { + if input.EditorID == "" { + return nil, ErrEditorConfigInvalid + } + editor, err := s.repo.GetEditorByID(ctx, input.EditorID) + if err != nil || editor == nil { + return nil, ErrEditorNotFound + } + if !isCustomKind(editor.Kind) { + return nil, ErrEditorConfigInvalid + } + if input.Name != nil { + editor.Name = strings.TrimSpace(*input.Name) + } + if input.Kind != nil { + if !isCustomKind(*input.Kind) { + return nil, ErrEditorConfigInvalid + } + editor.Kind = *input.Kind + } + if len(input.Config) > 0 { + editor.Config = input.Config + } + if input.Enabled != nil { + editor.Enabled = *input.Enabled + } + if err := validateEditorConfig(editor); err != nil { + return nil, err + } + if err := s.repo.UpdateEditor(ctx, editor); err != nil { + return nil, err + } + return editor, nil +} + +func (s *Service) DeleteEditor(ctx context.Context, editorID string) error { + if editorID == "" { + return ErrEditorConfigInvalid + } + editor, err := s.repo.GetEditorByID(ctx, editorID) + if err != nil { + return err + } + if editor == nil { + return ErrEditorNotFound + } + if !isCustomKind(editor.Kind) { + return ErrEditorConfigInvalid + } + if err := s.repo.DeleteEditor(ctx, editorID); err != nil { + return err + } + return s.userSettings.ClearDefaultEditorID(ctx, editorID) +} + +type commandConfig struct { + Command string `json:"command"` +} + +type remoteSSHConfig struct { + Host string `json:"host"` + User string `json:"user"` + Scheme string `json:"scheme"` +} + +type hostedURLConfig struct { + URL string `json:"url"` +} + +func parseCommandConfig(data json.RawMessage) (commandConfig, error) { + if len(data) == 0 { + return commandConfig{}, ErrEditorConfigInvalid + } + var cfg commandConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return commandConfig{}, ErrEditorConfigInvalid + } + if strings.TrimSpace(cfg.Command) == "" { + return commandConfig{}, ErrEditorConfigInvalid + } + return cfg, nil +} + +func parseRemoteSSHConfig(data json.RawMessage) (remoteSSHConfig, error) { + var cfg remoteSSHConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return remoteSSHConfig{}, ErrEditorConfigInvalid + } + if strings.TrimSpace(cfg.Host) == "" { + return remoteSSHConfig{}, ErrEditorConfigInvalid + } + return cfg, nil +} + +func parseHostedURLConfig(data json.RawMessage) (hostedURLConfig, error) { + var cfg hostedURLConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return hostedURLConfig{}, ErrEditorConfigInvalid + } + if strings.TrimSpace(cfg.URL) == "" { + return hostedURLConfig{}, ErrEditorConfigInvalid + } + return cfg, nil +} + +func launchCommand(commandTemplate, worktreePath, absPath string, line, column int) (string, error) { + expanded := expandCommandPlaceholders(commandTemplate, worktreePath, absPath, line, column) + parts := strings.Fields(expanded) + if len(parts) == 0 { + return "", ErrEditorConfigInvalid + } + cmd := exec.Command(parts[0], parts[1:]...) + if worktreePath != "" { + cmd.Dir = worktreePath + } + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("launch failed: %w", err) + } + return "", nil +} + +func expandCommandPlaceholders(template, worktreePath, absPath string, line, column int) string { + relPath := absPath + if worktreePath != "" && absPath != "" { + if relative, err := filepath.Rel(worktreePath, absPath); err == nil { + relPath = relative + } + } + replacements := map[string]string{ + "{cwd}": worktreePath, + "{file}": absPath, + "{rel}": relPath, + "{line}": strconv.Itoa(line), + "{column}": strconv.Itoa(column), + } + result := template + for key, value := range replacements { + result = strings.ReplaceAll(result, key, value) + } + return result +} + +func isCustomKind(kind string) bool { + switch kind { + case "custom_command", "custom_remote_ssh", "custom_hosted_url": + return true + default: + return false + } +} + +func validateEditorConfig(editor *models.Editor) error { + if editor == nil { + return ErrEditorConfigInvalid + } + switch editor.Kind { + case "custom_command": + _, err := parseCommandConfig(editor.Config) + return err + case "custom_remote_ssh": + _, err := parseRemoteSSHConfig(editor.Config) + return err + case "custom_hosted_url": + _, err := parseHostedURLConfig(editor.Config) + return err + default: + return ErrEditorConfigInvalid + } +} diff --git a/apps/backend/internal/editors/store/interface.go b/apps/backend/internal/editors/store/interface.go new file mode 100644 index 0000000000..f0f241f588 --- /dev/null +++ b/apps/backend/internal/editors/store/interface.go @@ -0,0 +1,18 @@ +package store + +import ( + "context" + + "github.com/kandev/kandev/internal/editors/models" +) + +type Repository interface { + ListEditors(ctx context.Context) ([]*models.Editor, error) + GetEditorByType(ctx context.Context, editorType string) (*models.Editor, error) + GetEditorByID(ctx context.Context, editorID string) (*models.Editor, error) + UpsertEditors(ctx context.Context, editors []*models.Editor) error + CreateEditor(ctx context.Context, editor *models.Editor) error + UpdateEditor(ctx context.Context, editor *models.Editor) error + DeleteEditor(ctx context.Context, editorID string) error + Close() error +} diff --git a/apps/backend/internal/editors/store/sqlite.go b/apps/backend/internal/editors/store/sqlite.go new file mode 100644 index 0000000000..44f19b0dcf --- /dev/null +++ b/apps/backend/internal/editors/store/sqlite.go @@ -0,0 +1,428 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/editors/discovery" + "github.com/kandev/kandev/internal/editors/models" +) + +type SQLiteRepository struct { + db *sql.DB +} + +var _ Repository = (*SQLiteRepository)(nil) + +func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { + normalizedPath := normalizeSQLitePath(dbPath) + if err := ensureSQLiteDir(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to prepare database path: %w", err) + } + if err := ensureSQLiteFile(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to create database file: %w", err) + } + dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath) + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + repo := &SQLiteRepository{db: db} + if err := repo.initSchema(); err != nil { + if closeErr := db.Close(); closeErr != nil { + return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr) + } + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + if err := repo.ensureDefaults(context.Background()); err != nil { + if closeErr := db.Close(); closeErr != nil { + return nil, fmt.Errorf("failed to close database after defaults error: %w", closeErr) + } + return nil, fmt.Errorf("failed to seed editor defaults: %w", err) + } + return repo, nil +} + +func (r *SQLiteRepository) Close() error { + return r.db.Close() +} + +func ensureSQLiteDir(dbPath string) error { + dir := filepath.Dir(dbPath) + if dir == "." || dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func ensureSQLiteFile(dbPath string) error { + file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return err + } + return file.Close() +} + +func normalizeSQLitePath(dbPath string) string { + if dbPath == "" { + return dbPath + } + abs, err := filepath.Abs(dbPath) + if err != nil { + return dbPath + } + return abs +} + +func (r *SQLiteRepository) initSchema() error { + schema := ` + CREATE TABLE IF NOT EXISTS editors ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + kind TEXT NOT NULL, + command TEXT NOT NULL, + scheme TEXT NOT NULL, + config TEXT, + installed INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + ` + _, err := r.db.Exec(schema) + if err != nil { + return err + } + columns := []columnDef{ + {name: "name", typ: "TEXT", defaultValue: "''"}, + {name: "kind", typ: "TEXT", defaultValue: "'built_in'"}, + {name: "config", typ: "TEXT", defaultValue: "NULL"}, + {name: "installed", typ: "INTEGER", defaultValue: "0"}, + } + for _, column := range columns { + if err := ensureColumn(r.db, "editors", column); err != nil { + return err + } + } + return nil +} + +func (r *SQLiteRepository) ensureDefaults(ctx context.Context) error { + definitions, err := discovery.LoadDefaults() + if err != nil { + return err + } + typeSet := make(map[string]struct{}, len(definitions)) + now := time.Now().UTC() + defaults := make([]*models.Editor, 0, len(definitions)) + for _, def := range definitions { + if def.Type == "" || def.Name == "" { + continue + } + typeSet[def.Type] = struct{}{} + installed := false + if def.Command != "" { + _, lookupErr := exec.LookPath(def.Command) + installed = lookupErr == nil + } + defaults = append(defaults, &models.Editor{ + ID: uuid.New().String(), + Type: def.Type, + Name: def.Name, + Kind: "built_in", + Command: def.Command, + Scheme: def.Scheme, + Installed: installed, + Enabled: def.Enabled, + CreatedAt: now, + UpdatedAt: now, + }) + } + if len(defaults) == 0 { + return fmt.Errorf("no editor defaults loaded") + } + if err := r.UpsertEditors(ctx, defaults); err != nil { + return err + } + if err := r.deleteBuiltinsNotIn(ctx, typeSet); err != nil { + return err + } + return nil +} + +func (r *SQLiteRepository) ListEditors(ctx context.Context) ([]*models.Editor, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at + FROM editors + ORDER BY name ASC + `) + if err != nil { + return nil, err + } + defer func() { + _ = rows.Close() + }() + + var editors []*models.Editor + for rows.Next() { + editor, err := scanEditor(rows) + if err != nil { + return nil, err + } + editors = append(editors, editor) + } + if err := rows.Err(); err != nil { + return nil, err + } + return editors, nil +} + +func (r *SQLiteRepository) GetEditorByType(ctx context.Context, editorType string) (*models.Editor, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at + FROM editors + WHERE type = ? + `, editorType) + return scanEditor(row) +} + +func (r *SQLiteRepository) GetEditorByID(ctx context.Context, editorID string) (*models.Editor, error) { + row := r.db.QueryRowContext(ctx, ` + SELECT id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at + FROM editors + WHERE id = ? + `, editorID) + return scanEditor(row) +} + +func (r *SQLiteRepository) UpsertEditors(ctx context.Context, editors []*models.Editor) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO editors (id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(type) DO UPDATE SET + name = excluded.name, + kind = excluded.kind, + command = excluded.command, + scheme = excluded.scheme, + config = excluded.config, + installed = excluded.installed, + enabled = excluded.enabled, + updated_at = excluded.updated_at + `) + if err != nil { + _ = tx.Rollback() + return err + } + defer func() { + if err := stmt.Close(); err != nil { + _ = tx.Rollback() + } + }() + + for _, editor := range editors { + if editor.ID == "" { + editor.ID = uuid.New().String() + } + if editor.CreatedAt.IsZero() { + editor.CreatedAt = time.Now().UTC() + } + editor.UpdatedAt = time.Now().UTC() + configValue, err := marshalConfig(editor.Config) + if err != nil { + _ = tx.Rollback() + return err + } + _, err = stmt.ExecContext( + ctx, + editor.ID, + editor.Type, + editor.Name, + editor.Kind, + editor.Command, + editor.Scheme, + configValue, + boolToInt(editor.Installed), + boolToInt(editor.Enabled), + editor.CreatedAt, + editor.UpdatedAt, + ) + if err != nil { + _ = tx.Rollback() + return err + } + } + + return tx.Commit() +} + +func (r *SQLiteRepository) CreateEditor(ctx context.Context, editor *models.Editor) error { + if editor == nil { + return fmt.Errorf("editor is nil") + } + if editor.ID == "" { + editor.ID = uuid.New().String() + } + if editor.CreatedAt.IsZero() { + editor.CreatedAt = time.Now().UTC() + } + editor.UpdatedAt = time.Now().UTC() + configValue, err := marshalConfig(editor.Config) + if err != nil { + return err + } + _, err = r.db.ExecContext(ctx, ` + INSERT INTO editors (id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, editor.ID, editor.Type, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, boolToInt(editor.Installed), boolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt) + return err +} + +func (r *SQLiteRepository) UpdateEditor(ctx context.Context, editor *models.Editor) error { + if editor == nil { + return fmt.Errorf("editor is nil") + } + editor.UpdatedAt = time.Now().UTC() + configValue, err := marshalConfig(editor.Config) + if err != nil { + return err + } + _, err = r.db.ExecContext(ctx, ` + UPDATE editors + SET name = ?, kind = ?, command = ?, scheme = ?, config = ?, installed = ?, enabled = ?, updated_at = ? + WHERE id = ? + `, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, boolToInt(editor.Installed), boolToInt(editor.Enabled), editor.UpdatedAt, editor.ID) + return err +} + +func (r *SQLiteRepository) DeleteEditor(ctx context.Context, editorID string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM editors WHERE id = ?`, editorID) + return err +} + +func scanEditor(scanner interface{ Scan(dest ...any) error }) (*models.Editor, error) { + editor := &models.Editor{} + var config sql.NullString + var installed int + var enabled int + if err := scanner.Scan( + &editor.ID, + &editor.Type, + &editor.Name, + &editor.Kind, + &editor.Command, + &editor.Scheme, + &config, + &installed, + &enabled, + &editor.CreatedAt, + &editor.UpdatedAt, + ); err != nil { + return nil, err + } + if config.Valid && config.String != "" { + editor.Config = json.RawMessage(config.String) + } + editor.Installed = installed == 1 + editor.Enabled = enabled == 1 + return editor, nil +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + +type columnDef struct { + name string + typ string + defaultValue string +} + +func ensureColumn(db *sql.DB, table string, column columnDef) error { + rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return err + } + defer func() { + _ = rows.Close() + }() + for rows.Next() { + var ( + cid int + name string + typ string + notNull int + defaultVal sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultVal, &pk); err != nil { + return err + } + if name == column.name { + return nil + } + } + alter := fmt.Sprintf( + "ALTER TABLE %s ADD COLUMN %s %s DEFAULT %s", + table, + column.name, + column.typ, + column.defaultValue, + ) + _, err = db.Exec(alter) + return err +} + +func marshalConfig(config json.RawMessage) (string, error) { + if len(config) == 0 { + return "", nil + } + if !json.Valid(config) { + return "", fmt.Errorf("invalid editor config json") + } + return string(config), nil +} + +func (r *SQLiteRepository) deleteBuiltinsNotIn(ctx context.Context, types map[string]struct{}) error { + if len(types) == 0 { + return nil + } + placeholders := make([]string, 0, len(types)) + args := make([]any, 0, len(types)+1) + for editorType := range types { + placeholders = append(placeholders, "?") + args = append(args, editorType) + } + query := fmt.Sprintf( + `DELETE FROM editors WHERE kind = 'built_in' AND type NOT IN (%s)`, + strings.Join(placeholders, ","), + ) + _, err := r.db.ExecContext(ctx, query, args...) + return err +} diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 111760aad7..3680cab6a6 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -49,6 +49,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS RepositoryIDs: req.RepositoryIDs, InitialSetupComplete: req.InitialSetupComplete, PreferredShell: req.PreferredShell, + DefaultEditorID: req.DefaultEditorID, }) 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 1115afe338..8720f5405b 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -20,6 +20,7 @@ type UserSettingsDTO struct { RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` UpdatedAt string `json:"updated_at"` } @@ -44,6 +45,7 @@ type UpdateUserSettingsRequest struct { 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"` } func FromUser(user *models.User) UserDTO { @@ -63,6 +65,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { RepositoryIDs: settings.RepositoryIDs, InitialSetupComplete: settings.InitialSetupComplete, PreferredShell: settings.PreferredShell, + DefaultEditorID: settings.DefaultEditorID, 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 e84a6b58df..d2348596e8 100644 --- a/apps/backend/internal/user/handlers/handlers.go +++ b/apps/backend/internal/user/handlers/handlers.go @@ -69,6 +69,7 @@ type httpUpdateUserSettingsRequest struct { 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"` } func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { @@ -83,6 +84,7 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { RepositoryIDs: body.RepositoryIDs, InitialSetupComplete: body.InitialSetupComplete, PreferredShell: body.PreferredShell, + DefaultEditorID: body.DefaultEditorID, }) if err != nil { h.logger.Error("failed to update user settings", zap.Error(err)) @@ -106,6 +108,7 @@ type wsUpdateUserSettingsRequest struct { 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"` } func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -119,6 +122,7 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (* RepositoryIDs: req.RepositoryIDs, InitialSetupComplete: req.InitialSetupComplete, PreferredShell: req.PreferredShell, + DefaultEditorID: req.DefaultEditorID, }) 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 6f02d88738..9d2ea040d8 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -16,6 +16,7 @@ type UserSettings struct { RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` 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 669babef8b..807723e3d2 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -29,6 +29,7 @@ type UpdateUserSettingsRequest struct { RepositoryIDs *[]string InitialSetupComplete *bool PreferredShell *string + DefaultEditorID *string } func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { @@ -84,6 +85,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if req.PreferredShell != nil { settings.PreferredShell = strings.TrimSpace(*req.PreferredShell) } + if req.DefaultEditorID != nil { + settings.DefaultEditorID = strings.TrimSpace(*req.DefaultEditorID) + } settings.UpdatedAt = time.Now().UTC() if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { return nil, err @@ -103,9 +107,30 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "repository_ids": settings.RepositoryIDs, "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, + "default_editor_id": settings.DefaultEditorID, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { s.logger.Error("failed to publish user settings event", zap.Error(err)) } } + +func (s *Service) ClearDefaultEditorID(ctx context.Context, editorID string) error { + if editorID == "" { + return nil + } + settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + if err != nil { + return err + } + if settings.DefaultEditorID != editorID { + return nil + } + settings.DefaultEditorID = "" + settings.UpdatedAt = time.Now().UTC() + if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { + return err + } + s.publishUserSettingsEvent(ctx, settings) + return nil +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 2c60bf24c5..6448c7ec77 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -192,6 +192,7 @@ func (r *SQLiteRepository) UpsertUserSettings(ctx context.Context, settings *mod "repository_ids": settings.RepositoryIDs, "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, + "default_editor_id": settings.DefaultEditorID, }) if err != nil { return err @@ -235,6 +236,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -244,5 +246,6 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.RepositoryIDs = payload.RepositoryIDs settings.InitialSetupComplete = payload.InitialSetupComplete settings.PreferredShell = payload.PreferredShell + settings.DefaultEditorID = payload.DefaultEditorID return settings, nil } diff --git a/apps/backend/pkg/api/v1/user.go b/apps/backend/pkg/api/v1/user.go index f02a4f5c9d..5b9caa9774 100644 --- a/apps/backend/pkg/api/v1/user.go +++ b/apps/backend/pkg/api/v1/user.go @@ -11,10 +11,11 @@ type User struct { } type UserSettings struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` - PreferredShell string `json:"preferred_shell"` - UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + PreferredShell string `json:"preferred_shell"` + DefaultEditorID string `json:"default_editor_id"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index f8bbfcf3e8..af5f603757 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -54,6 +54,7 @@ export default async function Page({ searchParams }: PageProps) { boardId: settingsBoardId, repositoryIds: settingsRepositoryIds, preferredShell: userSettings?.preferred_shell || null, + defaultEditorId: userSettings?.default_editor_id || null, loaded: Boolean(userSettings), }, }; diff --git a/apps/web/app/settings/general/editors/page.tsx b/apps/web/app/settings/general/editors/page.tsx new file mode 100644 index 0000000000..5a546e6155 --- /dev/null +++ b/apps/web/app/settings/general/editors/page.tsx @@ -0,0 +1,21 @@ +import { EditorsSettings } from '@/components/settings/editors-settings'; +import { fetchUserSettings, listEditors } from '@/lib/http'; +import type { EditorOption, UserSettings } from '@/lib/types/http'; + +export default async function GeneralEditorsPage() { + let editors: EditorOption[] = []; + let settings: UserSettings | null = null; + try { + const [editorsResponse, settingsResponse] = await Promise.all([ + listEditors({ cache: 'no-store' }), + fetchUserSettings({ cache: 'no-store' }), + ]); + editors = editorsResponse.editors ?? []; + settings = settingsResponse.settings ?? null; + } catch { + editors = []; + settings = null; + } + + return ; +} diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx index 0525c83aea..7419e0ed58 100644 --- a/apps/web/app/settings/general/page.tsx +++ b/apps/web/app/settings/general/page.tsx @@ -13,16 +13,11 @@ import { SettingsSection } from '@/components/settings/settings-section'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { fetchUserSettings, updateUserSettings } from '@/lib/http'; import { useRequest } from '@/lib/http/use-request'; -import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; import { getBackendConfig } from '@/lib/config'; -import type { Theme, Editor } from '@/lib/settings/types'; +import type { Theme } from '@/lib/settings/types'; export default function GeneralSettingsPage() { const { theme: currentTheme, setTheme } = useTheme(); - const [editor, setEditor] = useState(SETTINGS_DATA.general.editor); - const [customEditorCommand, setCustomEditorCommand] = useState( - SETTINGS_DATA.general.customEditorCommand || '' - ); const [backendUrl] = useState(() => getBackendConfig().apiBaseUrl); const [preferredShell, setPreferredShell] = useState(''); const [customShell, setCustomShell] = useState(''); @@ -142,52 +137,6 @@ export default function GeneralSettingsPage() { - } - title="Editor" - description="Choose your preferred code editor" - > - - - Editor Preference - - -
- - -
- - {editor === 'custom' && ( -
- - setCustomEditorCommand(e.target.value)} - placeholder="code --goto {file}:{line}" - /> -

- Use {'{file}'} and {'{line}'} as placeholders -

-
- )} -
-
-
- - - } title="Shell" diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 00c6d4bdfd..e4e3bd987f 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -6,6 +6,7 @@ import { listEnvironments, listExecutors, listWorkspaces, + fetchUserSettings, } from '@/lib/http'; export default function SettingsLayout({ @@ -21,13 +22,15 @@ export default function SettingsLayout({ async function SettingsLayoutServer({ children }: { children: React.ReactNode }) { let initialState = {}; try { - const [workspaces, executors, environments, agents, discovery] = await Promise.all([ + const [workspaces, executors, environments, agents, discovery, userSettings] = await Promise.all([ listWorkspaces({ cache: 'no-store' }), listExecutors({ cache: 'no-store' }), listEnvironments({ cache: 'no-store' }), listAgents({ cache: 'no-store' }), listAgentDiscovery({ cache: 'no-store' }), + fetchUserSettings({ cache: 'no-store' }).catch(() => null), ]); + const settings = userSettings?.settings; initialState = { workspaces: { items: workspaces.workspaces.map((workspace) => ({ @@ -65,6 +68,14 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode }) environmentsLoaded: true, agentsLoaded: true, }, + userSettings: { + workspaceId: settings?.workspace_id ?? null, + boardId: settings?.board_id ?? null, + repositoryIds: settings?.repository_ids ?? [], + preferredShell: settings?.preferred_shell ?? null, + defaultEditorId: settings?.default_editor_id ?? null, + loaded: Boolean(settings), + }, }; } catch { initialState = {}; diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx new file mode 100644 index 0000000000..0032a2db59 --- /dev/null +++ b/apps/web/components/settings/editors-settings.tsx @@ -0,0 +1,559 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import { Badge } from '@kandev/ui/badge'; +import { Button } from '@kandev/ui/button'; +import { Input } from '@kandev/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; +import { SettingsPageTemplate } from '@/components/settings/settings-page-template'; +import { Combobox, type ComboboxOption } from '@/components/combobox'; +import { useAppStore } from '@/components/state-provider'; +import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/http'; +import { useRequest } from '@/lib/http/use-request'; +import type { EditorOption, UserSettings } from '@/lib/types/http'; + +type EditorsSettingsProps = { + initialEditors: EditorOption[]; + initialSettings: UserSettings | null; +}; + +type CustomKind = 'custom_command' | 'custom_remote_ssh' | 'custom_hosted_url'; + +type EditorFormState = { + name: string; + kind: CustomKind; + command: string; + host: string; + user: string; + url: string; + scheme: string; + enabled: boolean; +}; + +const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; label: string }> = [ + { value: 'custom_command', label: 'Command' }, + { value: 'custom_remote_ssh', label: 'VS Code Remote SSH' }, + { value: 'custom_hosted_url', label: 'Hosted URL' }, +]; + +const PLACEHOLDER_HINT = '{cwd} {file} {rel} {line} {column}'; + +function getCustomKindLabel(kind: string) { + return CUSTOM_KIND_OPTIONS.find((option) => option.value === kind)?.label ?? 'Custom'; +} + +function isCustomEditor(editor: EditorOption) { + return editor.kind.startsWith('custom'); +} + +function editorConfigValue(editor: EditorOption, key: string) { + if (!editor.config || typeof editor.config !== 'object') { + return ''; + } + const value = (editor.config as Record)[key]; + return typeof value === 'string' ? value : ''; +} + +function getCustomEditorSummary(editor: EditorOption) { + switch (editor.kind) { + case 'custom_command': { + return editorConfigValue(editor, 'command') || getCustomKindLabel(editor.kind); + } + case 'custom_hosted_url': { + return editorConfigValue(editor, 'url') || getCustomKindLabel(editor.kind); + } + case 'custom_remote_ssh': { + const host = editorConfigValue(editor, 'host'); + const user = editorConfigValue(editor, 'user'); + if (host && user) return `${user}@${host}`; + if (host) return host; + return getCustomKindLabel(editor.kind); + } + default: + return getCustomKindLabel(editor.kind); + } +} + +function buildConfig(state: EditorFormState) { + switch (state.kind) { + case 'custom_command': + return { command: state.command }; + case 'custom_remote_ssh': + return { + host: state.host, + user: state.user || undefined, + scheme: state.scheme || undefined, + }; + case 'custom_hosted_url': + return { url: state.url }; + default: + return {}; + } +} + +function defaultFormState(): EditorFormState { + return { + name: '', + kind: 'custom_command', + command: '', + host: '', + user: '', + url: '', + scheme: '', + enabled: true, + }; +} + +function resolveEditorName(state: EditorFormState) { + const trimmed = state.name.trim(); + if (trimmed) return trimmed; + switch (state.kind) { + case 'custom_remote_ssh': + return state.host.trim(); + case 'custom_hosted_url': + return state.url.trim(); + case 'custom_command': + default: + return state.command.trim(); + } +} + +function formStateFromEditor(editor: EditorOption): EditorFormState { + return { + name: editor.name, + kind: (editor.kind as CustomKind) || 'custom_command', + command: editorConfigValue(editor, 'command'), + host: editorConfigValue(editor, 'host'), + user: editorConfigValue(editor, 'user'), + url: editorConfigValue(editor, 'url'), + scheme: editorConfigValue(editor, 'scheme'), + enabled: editor.enabled, + }; +} + +type EditorFormProps = { + title: string; + initialState: EditorFormState; + onCancel: () => void; + onSave: (state: EditorFormState) => void; + submitLabel: string; + isSaving: boolean; +}; + +function EditorForm({ + title, + initialState, + onCancel, + onSave, + submitLabel, + isSaving, +}: EditorFormProps) { + const [state, setState] = useState(initialState); + + useEffect(() => { + setState(initialState); + }, [initialState]); + + const setField = (key: K, value: EditorFormState[K]) => { + setState((prev) => ({ ...prev, [key]: value })); + }; + + const isValid = useMemo(() => { + const resolvedName = resolveEditorName(state); + if (!resolvedName) return false; + if (state.kind === 'custom_command') return Boolean(state.command.trim()); + if (state.kind === 'custom_remote_ssh') return Boolean(state.host.trim()); + if (state.kind === 'custom_hosted_url') return Boolean(state.url.trim()); + return false; + }, [state.command, state.host, state.kind, state.name, state.url]); + + return ( +
+
{title}
+ setField('name', event.target.value)} + placeholder="Editor name" + /> + + + {state.kind === 'custom_command' && ( +
+ setField('command', event.target.value)} + placeholder="code --goto {file}:{line}" + /> +

+ Supports placeholders: {PLACEHOLDER_HINT} +

+
+ )} + + {state.kind === 'custom_remote_ssh' && ( +
+ setField('host', event.target.value)} + placeholder="ssh-host.example.com" + /> + setField('user', event.target.value)} + placeholder="optional username" + /> + setField('scheme', event.target.value)} + placeholder="optional scheme (vscode, cursor)" + /> +

+ Opens a VS Code Remote SSH URL using the scheme (example: + {' '}vscode://vscode-remote/ssh-remote+user@host:/path/file:line). +

+
+ )} + + {state.kind === 'custom_hosted_url' && ( + setField('url', event.target.value)} + placeholder="https://code.example.com" + /> + )} + +
+ + +
+
+ ); +} + +function resolveAvailableEditors(editors: EditorOption[]) { + return editors.filter((editor) => { + if (!editor.enabled) return false; + if (editor.kind === 'built_in') return editor.installed; + return true; + }); +} + +function resolveDefaultEditorId(editors: EditorOption[], desiredId: string) { + const available = resolveAvailableEditors(editors); + if (desiredId && available.some((editor) => editor.id === desiredId)) { + return desiredId; + } + if (!desiredId && available.length > 0) { + return available[0].id; + } + return ''; +} + +export function EditorsSettings({ initialEditors, initialSettings }: EditorsSettingsProps) { + const setEditors = useAppStore((state) => state.setEditors); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const currentUserSettings = useAppStore((state) => state.userSettings); + const [editors, setEditorItems] = useState(() => initialEditors ?? []); + const initialDefaultId = resolveDefaultEditorId( + initialEditors ?? [], + initialSettings?.default_editor_id ?? '' + ); + const [defaultEditorId, setDefaultEditorId] = useState(initialDefaultId); + const [baselineDefaultId, setBaselineDefaultId] = useState(initialDefaultId); + const [isAdding, setIsAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + + const customEditors = useMemo(() => editors.filter(isCustomEditor), [editors]); + const builtInEditors = useMemo(() => editors.filter((editor) => !isCustomEditor(editor)), [editors]); + const availableEditors = useMemo(() => resolveAvailableEditors(editors), [editors]); + + const applyEditors = useCallback( + (updater: EditorOption[] | ((prev: EditorOption[]) => EditorOption[])) => { + setEditorItems((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater; + setEditors(next); + const resolvedDefault = resolveDefaultEditorId(next, defaultEditorId); + if (resolvedDefault !== defaultEditorId) { + setDefaultEditorId(resolvedDefault); + setBaselineDefaultId(resolvedDefault); + } + return next; + }); + }, + [defaultEditorId, setEditors] + ); + + useEffect(() => { + setEditors(editors); + }, [editors, setEditors]); + + const defaultOptions = useMemo(() => { + if (availableEditors.length === 0) return []; + const selected = defaultEditorId + ? availableEditors.filter((editor) => editor.id === defaultEditorId) + : []; + const rest = availableEditors.filter((editor) => editor.id !== defaultEditorId); + const ordered = [...selected, ...rest]; + return ordered.map((editor) => ({ + value: editor.id, + label: editor.name, + renderLabel: () => ( +
+ {editor.name} + {editor.kind === 'built_in' ? ( + + {editor.installed ? 'Installed' : 'Not installed'} + + ) : ( + + {getCustomKindLabel(editor.kind)} + + )} +
+ ), + })); + }, [availableEditors, defaultEditorId]); + + const isDirty = defaultEditorId !== baselineDefaultId; + + const saveDefaultRequest = useRequest(async () => { + const fallbackSettings = { + workspace_id: currentUserSettings.workspaceId ?? '', + board_id: currentUserSettings.boardId ?? '', + repository_ids: currentUserSettings.repositoryIds ?? [], + }; + const payload = { + workspace_id: initialSettings?.workspace_id ?? fallbackSettings.workspace_id, + board_id: initialSettings?.board_id ?? fallbackSettings.board_id, + repository_ids: initialSettings?.repository_ids ?? fallbackSettings.repository_ids, + default_editor_id: defaultEditorId || undefined, + }; + const response = await updateUserSettings(payload, { cache: 'no-store' }); + setBaselineDefaultId(defaultEditorId); + if (response?.settings) { + setUserSettings({ + workspaceId: response.settings.workspace_id || null, + boardId: response.settings.board_id || null, + repositoryIds: response.settings.repository_ids ?? [], + preferredShell: response.settings.preferred_shell || null, + defaultEditorId: response.settings.default_editor_id || null, + loaded: true, + }); + } + }); + + const createRequest = useRequest(async (state: EditorFormState) => { + const created = await createEditor( + { + name: state.name, + kind: state.kind, + config: buildConfig(state), + enabled: state.enabled, + }, + { cache: 'no-store' } + ); + applyEditors((prev: EditorOption[]) => [...prev, created]); + setIsAdding(false); + }); + + const updateRequest = useRequest(async (editorId: string, state: EditorFormState) => { + const updated = await updateEditor( + editorId, + { + name: state.name, + kind: state.kind, + config: buildConfig(state), + enabled: state.enabled, + }, + { cache: 'no-store' } + ); + applyEditors((prev: EditorOption[]) => + prev.map((editor: EditorOption) => (editor.id === editorId ? updated : editor)) + ); + setEditingId(null); + }); + + const deleteRequest = useRequest(async (editorId: string) => { + await deleteEditor(editorId, { cache: 'no-store' }); + applyEditors((prev: EditorOption[]) => + prev.filter((editor: EditorOption) => editor.id !== editorId) + ); + if (defaultEditorId === editorId) { + setDefaultEditorId(''); + setBaselineDefaultId(''); + setUserSettings({ + ...currentUserSettings, + defaultEditorId: null, + loaded: true, + }); + } + }); + + + + return ( + { + void saveDefaultRequest.run(); + }} + > +
+
+
Default editor
+
+
+ { + if (!value) return; + setDefaultEditorId(value); + }} + placeholder="Select a default editor" + searchPlaceholder="Search editors..." + emptyMessage="No editor found." + disabled={availableEditors.length === 0} + /> +
+
+
+ +
+
+
Custom editors
+ +
+ + {isAdding && ( + setIsAdding(false)} + onSave={(state) => { + void createRequest.run(state); + }} + submitLabel="Add editor" + isSaving={createRequest.isLoading} + /> + )} + +
+ {customEditors.length === 0 && !isAdding && ( +
+ No custom editors yet. +
+ )} + {customEditors.map((editor) => { + if (editingId === editor.id) { + return ( + setEditingId(null)} + onSave={(state) => { + void updateRequest.run(editor.id, state); + }} + submitLabel="Save changes" + isSaving={updateRequest.isLoading} + /> + ); + } + return ( +
+
+
{editor.name}
+
+ {getCustomEditorSummary(editor)} +
+
+
+ + +
+
+ ); + })} +
+
+ + {builtInEditors.length === 0 ? null : ( +
+
+ Supported Editors +
+
+ {builtInEditors.map((editor) => ( +
+ {editor.name} + + {editor.installed ? 'Installed' : 'Not installed'} + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 10dc5d4139..34fe7fc4c4 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -8,6 +8,7 @@ import { IconServer, IconRobot, IconBell, + IconCode, IconChevronRight, IconCpu, IconPlug, @@ -91,6 +92,18 @@ export function SettingsAppSidebar() { + + + + + Editors + + + diff --git a/apps/web/components/task/chat/messages/permission-request-message.tsx b/apps/web/components/task/chat/messages/permission-request-message.tsx index 01bd2e1e11..8067357b21 100644 --- a/apps/web/components/task/chat/messages/permission-request-message.tsx +++ b/apps/web/components/task/chat/messages/permission-request-message.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useState } from 'react'; -import { IconAlertTriangle, IconCheck, IconX, IconLoader2 } from '@tabler/icons-react'; +import { IconAlertTriangle, IconCheck, IconX } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; import { getWebSocketClient } from '@/lib/ws/connection'; import type { Message } from '@/lib/types/http'; diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx index 8af25ad0cf..f0408be210 100644 --- a/apps/web/components/task/chat/messages/tool-call-message.tsx +++ b/apps/web/components/task/chat/messages/tool-call-message.tsx @@ -9,7 +9,6 @@ import { IconEdit, IconEye, IconFile, - IconLoader2, IconSearch, IconTerminal2, IconX, diff --git a/apps/web/components/task/editors-menu.tsx b/apps/web/components/task/editors-menu.tsx new file mode 100644 index 0000000000..cbcc1d210a --- /dev/null +++ b/apps/web/components/task/editors-menu.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useMemo } from 'react'; +import { IconChevronDown, IconCode, IconLoader2 } from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@kandev/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { useEditors } from '@/hooks/use-editors'; +import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; +import { useAppStore } from '@/components/state-provider'; + +type EditorsMenuProps = { + activeSessionId: string | null; +}; + +export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { + const openEditor = useOpenSessionInEditor(activeSessionId ?? null); + const { editors } = useEditors(); + const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId); + console.warn("here[17]: editors-menu.tsx:19: defaultEditorId=", defaultEditorId) + + const enabledEditors = useMemo( + () => + editors.filter((editor) => { + if (!editor.enabled) return false; + if (editor.kind === 'built_in') return editor.installed; + return true; + }), + [editors] + ); + console.warn("here[15]: editors-menu.tsx:21: enabledEditors=", enabledEditors) + + const resolvedEditorId = useMemo(() => { + if (defaultEditorId && enabledEditors.some((editor) => editor.id === defaultEditorId)) { + return defaultEditorId; + } + return enabledEditors[0]?.id ?? ''; + }, [defaultEditorId, enabledEditors]); + console.warn("here[16]: editors-menu.tsx:32: resolvedEditorId=", resolvedEditorId) + + return ( +
+ + + + + + {activeSessionId ? 'Open in editor' : 'Select a session to open its worktree'} + + + + + + + + {enabledEditors.length === 0 ? ( + No editors available + ) : ( + enabledEditors.map((editor) => ( + { + void openEditor.open({ editorId: editor.id }); + }} + > + {editor.name} + + )) + )} + + +
+ ); +} diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index 71316218d7..66141f5273 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -13,6 +13,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { LineStat } from '@/components/diff-stat'; import { useAppStore } from '@/components/state-provider'; +import { useOpenSessionInEditor } from '@/hooks/use-open-session-in-editor'; import { useSessionGitStatus } from '@/hooks/use-session-git-status'; import type { FileInfo } from '@/lib/state/store'; import { FileBrowser } from '@/components/task/file-browser'; @@ -79,6 +80,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi const [topTab, setTopTab] = useState<'diff' | 'files'>('diff'); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId); + const openEditor = useOpenSessionInEditor(activeSessionId ?? null); // Convert git status files to array for display const changedFiles = useMemo(() => { @@ -155,6 +157,8 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiffPath, onOpenFi className="text-muted-foreground hover:text-foreground" onClick={(event) => { event.stopPropagation(); + if (!activeSessionId) return; + void openEditor.open({ filePath: file.path }); }} > diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 135096ce99..e566d86c5c 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { TaskState } from '@/lib/types/http'; import { TaskSwitcher } from './task-switcher'; import { Button } from '@kandev/ui/button'; @@ -11,15 +11,6 @@ import { linkToSession } from '@/lib/links'; import { listTaskSessions } from '@/lib/http'; import { useTasks } from '@/hooks/use-tasks'; -type TaskSummary = { - id: string; - title: string; - state?: TaskState; - description?: string; - columnId?: string; - repositoryPath?: string; -}; - type TaskSessionSidebarProps = { workspaceId: string | null; boardId: string | null; diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 67c368b582..19412328ba 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -1,31 +1,20 @@ 'use client'; -import { memo, useMemo, useState } from 'react'; +import { memo, useState } from 'react'; import Link from 'next/link'; import { IconArrowLeft, IconArrowUp, - IconBrandVscode, - IconChevronDown, IconCopy, IconDots, IconFolderOpen, IconGitBranch, IconGitMerge, IconGitPullRequest, - IconLoader2, - IconPlayerPlay, - IconPlayerStop, + IconChevronDown, IconCheck, } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@kandev/ui/dialog'; import { DropdownMenu, DropdownMenuContent, @@ -33,12 +22,11 @@ import { DropdownMenuTrigger, } from '@kandev/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover'; import { CommitStatBadge, LineStat } from '@/components/diff-stat'; -import { useAppStore } from '@/components/state-provider'; import { useSessionGitStatus } from '@/hooks/use-session-git-status'; import { formatUserHomePath } from '@/lib/utils'; +import { EditorsMenu } from '@/components/task/editors-menu'; type TaskTopBarProps = { taskId?: string | null; @@ -60,10 +48,6 @@ const TaskTopBar = memo(function TaskTopBar({ activeSessionId, taskTitle, baseBranch, - onStartAgent, - onStopAgent, - isAgentRunning = false, - isAgentLoading = false, worktreePath, worktreeBranch, repositoryPath, @@ -73,20 +57,8 @@ const TaskTopBar = memo(function TaskTopBar({ const [popoverOpen, setPopoverOpen] = useState(false); const [copiedRepo, setCopiedRepo] = useState(false); const [copiedWorktree, setCopiedWorktree] = useState(false); - const [startDialogOpen, setStartDialogOpen] = useState(false); - const [selectedAgentProfileId, setSelectedAgentProfileId] = useState(null); const gitStatus = useSessionGitStatus(activeSessionId ?? null); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); - - const resolvedAgentProfileId = useMemo(() => { - if (selectedAgentProfileId && agentProfiles.some((profile) => profile.id === selectedAgentProfileId)) { - return selectedAgentProfileId; - } - return agentProfiles[0]?.id ?? ''; - }, [agentProfiles, selectedAgentProfileId]); - - // Use worktree branch if available, otherwise fall back to base branch const displayBranch = worktreeBranch || baseBranch; @@ -265,24 +237,7 @@ const TaskTopBar = memo(function TaskTopBar({
- {/* Editor Button */} - - - - - Open in editor - + {/* Create PR Split Button */}
diff --git a/apps/web/hooks/use-editors.ts b/apps/web/hooks/use-editors.ts new file mode 100644 index 0000000000..03302d0768 --- /dev/null +++ b/apps/web/hooks/use-editors.ts @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect } from 'react'; +import { fetchUserSettings, listEditors } from '@/lib/http'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { useAppStore } from '@/components/state-provider'; + +export function useEditors() { + const editors = useAppStore((state) => state.editors.items); + const loaded = useAppStore((state) => state.editors.loaded); + const loading = useAppStore((state) => state.editors.loading); + const setEditors = useAppStore((state) => state.setEditors); + const setEditorsLoading = useAppStore((state) => state.setEditorsLoading); + const userSettingsLoaded = useAppStore((state) => state.userSettings.loaded); + const setUserSettings = useAppStore((state) => state.setUserSettings); + + useEffect(() => { + const client = getWebSocketClient(); + if (client) { + // Future: subscribe to editor update events. + client.subscribeUser(); + } + }, []); + + useEffect(() => { + if (loaded || loading) { + return; + } + setEditorsLoading(true); + listEditors({ cache: 'no-store' }) + .then((response) => { + setEditors(response.editors ?? []); + }) + .catch(() => { + setEditors([]); + }) + .finally(() => { + setEditorsLoading(false); + }); + }, [loaded, loading, setEditors, setEditorsLoading]); + + useEffect(() => { + if (userSettingsLoaded) return; + fetchUserSettings({ cache: 'no-store' }) + .then((data) => { + if (!data?.settings) return; + setUserSettings({ + workspaceId: data.settings.workspace_id || null, + boardId: data.settings.board_id || null, + repositoryIds: data.settings.repository_ids ?? [], + preferredShell: data.settings.preferred_shell || null, + defaultEditorId: data.settings.default_editor_id || null, + loaded: true, + }); + }) + .catch(() => { + // Ignore settings fetch errors for now. + }); + }, [setUserSettings, userSettingsLoaded]); + + return { + editors, + loaded, + loading, + }; +} diff --git a/apps/web/hooks/use-open-session-in-editor.ts b/apps/web/hooks/use-open-session-in-editor.ts new file mode 100644 index 0000000000..dd3d4ea6fd --- /dev/null +++ b/apps/web/hooks/use-open-session-in-editor.ts @@ -0,0 +1,41 @@ +'use client'; + +import { openSessionInEditor } from '@/lib/http'; +import { useRequest } from '@/lib/http/use-request'; + +type OpenEditorOptions = { + filePath?: string; + line?: number; + column?: number; + editorId?: string; + editorType?: string; +}; + +export function useOpenSessionInEditor(sessionId?: string | null) { + const request = useRequest(async (options?: OpenEditorOptions) => { + if (!sessionId) { + return null; + } + const response = await openSessionInEditor( + sessionId, + { + editor_id: options?.editorId, + editor_type: options?.editorType, + file_path: options?.filePath, + line: options?.line, + column: options?.column, + }, + { cache: 'no-store' } + ); + if (response?.url) { + window.open(response.url, '_blank', 'noopener,noreferrer'); + } + return response ?? null; + }); + + return { + open: (options?: OpenEditorOptions) => request.run(options), + status: request.status, + isLoading: request.isLoading, + }; +} diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 69d7ffa4b9..d5902da788 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -10,6 +10,7 @@ type DisplaySettings = { boardId: string | null; repositoryIds: string[]; preferredShell: string | null; + defaultEditorId: string | null; loaded: boolean; }; @@ -45,6 +46,7 @@ export function useUserDisplaySettings({ boardId: next.boardId, repositoryIds, preferredShell: next.preferredShell ?? userSettings.preferredShell ?? null, + defaultEditorId: userSettings.defaultEditorId ?? null, loaded: true, }; const sameWorkspace = normalized.workspaceId === userSettings.workspaceId; @@ -86,6 +88,7 @@ export function useUserDisplaySettings({ boardId: data.settings.board_id || null, repositoryIds, preferredShell: data.settings.preferred_shell || null, + defaultEditorId: data.settings.default_editor_id || null, loaded: true, }); }) diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts index 173f779141..25fec102c2 100644 --- a/apps/web/lib/http/index.ts +++ b/apps/web/lib/http/index.ts @@ -12,6 +12,8 @@ import type { RepositoryBranchesResponse, NotificationProvidersResponse, NotificationProvider, + EditorsResponse, + EditorOption, CreateTaskResponse, Task, TaskSessionsResponse, @@ -165,6 +167,7 @@ export async function updateUserSettings( board_id: string; repository_ids: string[]; preferred_shell?: string; + default_editor_id?: string; }, options?: ApiRequestOptions ) { @@ -174,6 +177,59 @@ export async function updateUserSettings( }); } +export async function listEditors(options?: ApiRequestOptions) { + return fetchJson('/api/v1/editors', options); +} + +export async function createEditor( + payload: { + name: string; + kind: string; + config?: Record; + enabled?: boolean; + }, + options?: ApiRequestOptions +) { + return fetchJson('/api/v1/editors', { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function updateEditor( + editorId: string, + payload: { + name?: string; + kind?: string; + config?: Record; + enabled?: boolean; + }, + options?: ApiRequestOptions +) { + return fetchJson(`/api/v1/editors/${editorId}`, { + ...options, + init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + +export async function deleteEditor(editorId: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/editors/${editorId}`, { + ...options, + init: { method: 'DELETE', ...(options?.init ?? {}) }, + }); +} + +export async function openSessionInEditor( + sessionId: string, + payload: Partial<{ editor_id: string; editor_type: string; file_path: string; line: number; column: number }>, + options?: ApiRequestOptions +) { + return fetchJson<{ url?: string }>(`/api/v1/task-sessions/${sessionId}/open-editor`, { + ...options, + init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }); +} + export async function listNotificationProviders(options?: ApiRequestOptions) { return fetchJson('/api/v1/notification-providers', options); } diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 2ef4bf3e5d..9e080e21d8 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -4,6 +4,7 @@ import type { AgentDiscovery, Branch, Environment, + EditorOption, Executor, Message, Repository, @@ -64,6 +65,12 @@ export type AgentDiscoveryState = { items: AgentDiscovery[]; }; +export type EditorsState = { + items: EditorOption[]; + loaded: boolean; + loading: boolean; +}; + export type BoardState = { items: Array<{ id: string; workspaceId: string; name: string }>; activeId: string | null; @@ -106,6 +113,7 @@ export type UserSettingsState = { boardId: string | null; repositoryIds: string[]; preferredShell: string | null; + defaultEditorId: string | null; loaded: boolean; }; @@ -224,6 +232,7 @@ export type AppState = { repositories: RepositoriesState; repositoryBranches: RepositoryBranchesState; settingsData: SettingsDataState; + editors: EditorsState; tasks: TaskState; agents: AgentState; agentProfiles: AgentProfilesState; @@ -254,6 +263,8 @@ export type AppState = { setRepositoryBranches: (repositoryId: string, branches: Branch[]) => void; setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; setSettingsData: (next: Partial) => void; + setEditors: (editors: EditorsState['items']) => void; + setEditorsLoading: (loading: boolean) => void; setUserSettings: (settings: UserSettingsState) => void; setTerminalOutput: (terminalId: string, data: string) => void; appendShellOutput: (sessionId: string, data: string) => void; @@ -307,6 +318,7 @@ const defaultState: AppState = { repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} }, repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} }, settingsData: { executorsLoaded: false, environmentsLoaded: false, agentsLoaded: false }, + editors: { items: [], loaded: false, loading: false }, tasks: { activeTaskId: null, activeSessionId: null }, agents: { agents: [] }, agentProfiles: { items: [], version: 0 }, @@ -315,6 +327,7 @@ const defaultState: AppState = { boardId: null, repositoryIds: [], preferredShell: null, + defaultEditorId: null, loaded: false, }, terminal: { terminals: [] }, @@ -343,6 +356,8 @@ const defaultState: AppState = { setRepositoryBranches: () => undefined, setRepositoryBranchesLoading: () => undefined, setSettingsData: () => undefined, + setEditors: () => undefined, + setEditorsLoading: () => undefined, setUserSettings: () => undefined, setTerminalOutput: () => undefined, appendShellOutput: () => undefined, @@ -388,6 +403,8 @@ function mergeInitialState( | 'setRepositoryBranches' | 'setRepositoryBranchesLoading' | 'setSettingsData' + | 'setEditors' + | 'setEditorsLoading' | 'setUserSettings' | 'setTerminalOutput' | 'appendShellOutput' @@ -424,6 +441,7 @@ function mergeInitialState( repositories: { ...defaultState.repositories, ...initialState.repositories }, repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches }, settingsData: { ...defaultState.settingsData, ...initialState.settingsData }, + editors: { ...defaultState.editors, ...initialState.editors }, kanban: { ...defaultState.kanban, ...initialState.kanban }, tasks: { ...defaultState.tasks, ...initialState.tasks }, agents: { ...defaultState.agents, ...initialState.agents }, @@ -463,6 +481,7 @@ export function createAppStore(initialState?: Partial) { if (state.repositories) Object.assign(draft.repositories, state.repositories); if (state.repositoryBranches) Object.assign(draft.repositoryBranches, state.repositoryBranches); if (state.settingsData) Object.assign(draft.settingsData, state.settingsData); + if (state.editors) Object.assign(draft.editors, state.editors); if (state.kanban) Object.assign(draft.kanban, state.kanban); if (state.tasks) Object.assign(draft.tasks, state.tasks); if (state.agents) Object.assign(draft.agents, state.agents); @@ -558,6 +577,15 @@ export function createAppStore(initialState?: Partial) { set((draft) => { draft.settingsData = { ...draft.settingsData, ...next }; }), + setEditors: (editors) => + set((draft) => { + draft.editors.items = editors; + draft.editors.loaded = true; + }), + setEditorsLoading: (loading) => + set((draft) => { + draft.editors.loading = loading; + }), setUserSettings: (settings) => set((draft) => { draft.userSettings = settings; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 41e7555917..52f9f95319 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -242,6 +242,7 @@ export type UserSettingsUpdatedPayload = { repository_ids: string[]; initial_setup_complete?: boolean; preferred_shell?: string; + default_editor_id?: string; updated_at?: string; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 1ee225d069..4b958ff6bd 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -178,6 +178,7 @@ export type UserSettings = { repository_ids: string[]; initial_setup_complete?: boolean; preferred_shell?: string; + default_editor_id?: string; updated_at: string; }; @@ -186,6 +187,22 @@ export type UserSettingsResponse = { shell_options?: Array<{ value: string; label: string }>; }; +export type EditorOption = { + id: string; + type: string; + name: string; + kind: string; + command?: string; + scheme?: string; + config?: Record; + installed: boolean; + enabled: boolean; +}; + +export type EditorsResponse = { + editors: EditorOption[]; +}; + export type UserResponse = { user: User; settings: UserSettings; diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index c1e4ba4ecc..e7d3ceaa0c 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -13,6 +13,7 @@ export function registerUsersHandlers(store: StoreApi): WsHandlers { boardId: message.payload.board_id || null, repositoryIds, preferredShell: message.payload.preferred_shell || null, + defaultEditorId: message.payload.default_editor_id || null, loaded: true, }, }));