diff --git a/apps/backend/internal/editors/controller/controller.go b/apps/backend/internal/editors/controller/controller.go index d187042815..f886739e11 100644 --- a/apps/backend/internal/editors/controller/controller.go +++ b/apps/backend/internal/editors/controller/controller.go @@ -63,6 +63,7 @@ func (c *Controller) OpenSessionEditor(ctx context.Context, sessionID string, re FilePath: req.FilePath, Line: req.Line, Column: req.Column, + WorktreeID: req.WorktreeID, }) if err != nil { return dto.OpenEditorResponse{}, err @@ -70,8 +71,8 @@ func (c *Controller) OpenSessionEditor(ctx context.Context, sessionID string, re return dto.OpenEditorResponse{URL: url}, nil } -func (c *Controller) OpenFolder(ctx context.Context, sessionID string) (dto.OpenFolderResponse, error) { - if err := c.service.OpenFolder(ctx, sessionID); err != nil { +func (c *Controller) OpenFolder(ctx context.Context, sessionID string, req dto.OpenFolderRequest) (dto.OpenFolderResponse, error) { + if err := c.service.OpenFolder(ctx, sessionID, req.WorktreeID); err != nil { return dto.OpenFolderResponse{Success: false}, err } return dto.OpenFolderResponse{Success: true}, nil diff --git a/apps/backend/internal/editors/dto/dto.go b/apps/backend/internal/editors/dto/dto.go index d95e858cb1..2de747bb05 100644 --- a/apps/backend/internal/editors/dto/dto.go +++ b/apps/backend/internal/editors/dto/dto.go @@ -31,6 +31,9 @@ type OpenEditorRequest struct { FilePath string `json:"file_path,omitempty"` Line int `json:"line,omitempty"` Column int `json:"column,omitempty"` + // WorktreeID selects which of the session's worktrees to open + // (multi-repo sessions); empty falls back to the first worktree. + WorktreeID string `json:"worktree_id,omitempty"` } type OpenEditorResponse struct { @@ -70,6 +73,12 @@ type UpdateEditorRequest struct { Enabled *bool `json:"enabled,omitempty"` } +type OpenFolderRequest struct { + // WorktreeID selects which of the session's worktrees to open + // (multi-repo sessions); empty falls back to the first worktree. + WorktreeID string `json:"worktree_id,omitempty"` +} + type OpenFolderResponse struct { Success bool `json:"success"` } diff --git a/apps/backend/internal/editors/handlers/handlers.go b/apps/backend/internal/editors/handlers/handlers.go index 2ccb98516e..c3326ecb2d 100644 --- a/apps/backend/internal/editors/handlers/handlers.go +++ b/apps/backend/internal/editors/handlers/handlers.go @@ -1,6 +1,8 @@ package handlers import ( + "errors" + "io" "net/http" "github.com/gin-gonic/gin" @@ -9,8 +11,24 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/editors/controller" "github.com/kandev/kandev/internal/editors/dto" + "github.com/kandev/kandev/internal/editors/service" ) +// serviceErrorStatus maps known service errors to HTTP status codes so +// client mistakes (bad worktree_id, unknown editor) don't surface as 500s. +func serviceErrorStatus(err error) int { + switch { + case errors.Is(err, service.ErrEditorNotFound), errors.Is(err, service.ErrWorkspaceNotFound): + return http.StatusNotFound + case errors.Is(err, service.ErrEditorConfigInvalid): + return http.StatusBadRequest + case errors.Is(err, service.ErrEditorUnavailable): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} + type Handlers struct { controller *controller.Controller logger *logger.Logger @@ -53,7 +71,7 @@ func (h *Handlers) httpOpenSessionEditor(c *gin.Context) { 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"}) + c.JSON(serviceErrorStatus(err), gin.H{"error": "failed to open editor"}) return } c.JSON(http.StatusOK, resp) @@ -99,10 +117,18 @@ func (h *Handlers) httpDeleteEditor(c *gin.Context) { } func (h *Handlers) httpOpenFolder(c *gin.Context) { - resp, err := h.controller.OpenFolder(c.Request.Context(), c.Param("id")) + var req dto.OpenFolderRequest + // The body is optional for backward compatibility; an empty body decodes + // as EOF and keeps the zero value. Checking ContentLength instead would + // silently drop the payload of chunked requests (ContentLength == -1). + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.OpenFolder(c.Request.Context(), c.Param("id"), req) if err != nil { h.logger.Error("failed to open folder", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open folder"}) + c.JSON(serviceErrorStatus(err), gin.H{"error": "failed to open folder"}) return } c.JSON(http.StatusOK, resp) diff --git a/apps/backend/internal/editors/handlers/handlers_test.go b/apps/backend/internal/editors/handlers/handlers_test.go new file mode 100644 index 0000000000..40b0c376a3 --- /dev/null +++ b/apps/backend/internal/editors/handlers/handlers_test.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/kandev/kandev/internal/editors/service" +) + +func TestServiceErrorStatus(t *testing.T) { + tests := []struct { + err error + expected int + }{ + {service.ErrWorkspaceNotFound, http.StatusNotFound}, + {service.ErrEditorNotFound, http.StatusNotFound}, + {service.ErrEditorConfigInvalid, http.StatusBadRequest}, + {service.ErrEditorUnavailable, http.StatusConflict}, + {fmt.Errorf("wrapped: %w", service.ErrWorkspaceNotFound), http.StatusNotFound}, + {errors.New("something else"), http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.err.Error(), func(t *testing.T) { + if got := serviceErrorStatus(tt.err); got != tt.expected { + t.Errorf("serviceErrorStatus(%v) = %d, want %d", tt.err, got, tt.expected) + } + }) + } +} diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go index e7a65f2ee9..c2f2cebd05 100644 --- a/apps/backend/internal/editors/service/service.go +++ b/apps/backend/internal/editors/service/service.go @@ -72,6 +72,7 @@ type OpenEditorInput struct { FilePath string Line int Column int + WorktreeID string } func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string, error) { @@ -82,7 +83,7 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string if err != nil { return "", err } - worktreePath, err := s.resolveSessionPath(ctx, session) + worktreePath, err := s.resolveSessionPath(ctx, session, input.WorktreeID) if err != nil { return "", err } @@ -183,7 +184,7 @@ func openBuiltinEditor(editor *models.Editor, absPath string, line, column int) return "", nil } -func (s *Service) OpenFolder(ctx context.Context, sessionID string) error { +func (s *Service) OpenFolder(ctx context.Context, sessionID, worktreeID string) error { if sessionID == "" { return ErrEditorConfigInvalid } @@ -191,7 +192,7 @@ func (s *Service) OpenFolder(ctx context.Context, sessionID string) error { if err != nil { return err } - worktreePath, err := s.resolveSessionPath(ctx, session) + worktreePath, err := s.resolveSessionPath(ctx, session, worktreeID) if err != nil { return err } @@ -250,10 +251,13 @@ func (s *Service) resolveEditor(ctx context.Context, editorID, editorType, fallb return editor, nil } -func (s *Service) resolveSessionPath(ctx context.Context, session *taskmodels.TaskSession) (string, error) { +func (s *Service) resolveSessionPath(ctx context.Context, session *taskmodels.TaskSession, worktreeID string) (string, error) { if session == nil { return "", ErrWorkspaceNotFound } + if worktreeID != "" { + return findSessionWorktreePath(session, worktreeID) + } if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" { return session.Worktrees[0].WorktreePath, nil } @@ -269,6 +273,22 @@ func (s *Service) resolveSessionPath(ctx context.Context, session *taskmodels.Ta return "", ErrWorkspaceNotFound } +// findSessionWorktreePath returns the path of the session worktree matching +// worktreeID. It matches both the worktree ID and the session-worktree +// association ID, since clients may hold either. +func findSessionWorktreePath(session *taskmodels.TaskSession, worktreeID string) (string, error) { + for _, worktree := range session.Worktrees { + if worktree == nil || (worktree.WorktreeID != worktreeID && worktree.ID != worktreeID) { + continue + } + if worktree.WorktreePath == "" { + return "", ErrWorkspaceNotFound + } + return worktree.WorktreePath, nil + } + return "", ErrWorkspaceNotFound +} + func (s *Service) resolveFilePath(worktreePath, filePath string) (string, error) { if worktreePath == "" { return "", ErrWorkspaceNotFound diff --git a/apps/backend/internal/editors/service/service_test.go b/apps/backend/internal/editors/service/service_test.go index 6f495f3073..f32aa950a5 100644 --- a/apps/backend/internal/editors/service/service_test.go +++ b/apps/backend/internal/editors/service/service_test.go @@ -9,7 +9,7 @@ import ( func TestOpenFolder_EmptySessionID(t *testing.T) { svc := &Service{} - err := svc.OpenFolder(context.Background(), "") + err := svc.OpenFolder(context.Background(), "", "") if err != ErrEditorConfigInvalid { t.Errorf("expected ErrEditorConfigInvalid, got %v", err) } @@ -17,7 +17,7 @@ func TestOpenFolder_EmptySessionID(t *testing.T) { func TestResolveSessionPath_NilSession(t *testing.T) { svc := &Service{} - _, err := svc.resolveSessionPath(context.Background(), nil) + _, err := svc.resolveSessionPath(context.Background(), nil, "") if err != ErrWorkspaceNotFound { t.Errorf("expected ErrWorkspaceNotFound, got %v", err) } @@ -31,7 +31,7 @@ func TestResolveSessionPath_WithWorktree(t *testing.T) { {WorktreePath: "/path/to/worktree"}, }, } - path, err := svc.resolveSessionPath(context.Background(), session) + path, err := svc.resolveSessionPath(context.Background(), session, "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -48,7 +48,57 @@ func TestResolveSessionPath_EmptyWorktreePath(t *testing.T) { {WorktreePath: ""}, }, } - _, err := svc.resolveSessionPath(context.Background(), session) + _, err := svc.resolveSessionPath(context.Background(), session, "") + if err != ErrWorkspaceNotFound { + t.Errorf("expected ErrWorkspaceNotFound, got %v", err) + } +} + +func TestResolveSessionPath_SelectsWorktreeByID(t *testing.T) { + svc := &Service{} + session := &taskmodels.TaskSession{ + ID: "session-1", + Worktrees: []*taskmodels.TaskSessionWorktree{ + {ID: "assoc-1", WorktreeID: "wt-1", WorktreePath: "/path/to/repo-a"}, + {ID: "assoc-2", WorktreeID: "wt-2", WorktreePath: "/path/to/repo-b"}, + }, + } + + tests := []struct { + name string + worktreeID string + expected string + expectErr error + }{ + {name: "by worktree id", worktreeID: "wt-2", expected: "/path/to/repo-b"}, + {name: "by association id", worktreeID: "assoc-2", expected: "/path/to/repo-b"}, + {name: "empty falls back to first", worktreeID: "", expected: "/path/to/repo-a"}, + {name: "unknown id", worktreeID: "wt-missing", expectErr: ErrWorkspaceNotFound}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := svc.resolveSessionPath(context.Background(), session, tt.worktreeID) + if err != tt.expectErr { + t.Fatalf("expected error %v, got %v", tt.expectErr, err) + } + if path != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, path) + } + }) + } +} + +func TestResolveSessionPath_SelectedWorktreeEmptyPath(t *testing.T) { + svc := &Service{} + session := &taskmodels.TaskSession{ + ID: "session-1", + Worktrees: []*taskmodels.TaskSessionWorktree{ + {ID: "assoc-1", WorktreeID: "wt-1", WorktreePath: "/path/to/repo-a"}, + {ID: "assoc-2", WorktreeID: "wt-2", WorktreePath: ""}, + }, + } + _, err := svc.resolveSessionPath(context.Background(), session, "wt-2") if err != ErrWorkspaceNotFound { t.Errorf("expected ErrWorkspaceNotFound, got %v", err) } diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index 5345ade4ab..54044d414c 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -3,6 +3,7 @@ package dto import ( "testing" + "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" ) @@ -56,3 +57,30 @@ func TestFromWorkflowStep_PreservesWIPFields(t *testing.T) { t.Fatalf("PullFromStepID = %q, want queue-step", got.PullFromStepID) } } + +func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) { + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-1", + Worktrees: []*models.TaskSessionWorktree{ + {ID: "assoc-1", WorktreeID: "wt-1", RepositoryID: "repo-a", WorktreePath: "/x/a"}, + {ID: "assoc-2", WorktreeID: "wt-2", RepositoryID: "repo-b", WorktreePath: "/x/b"}, + }, + } + + full := FromTaskSession(session) + if len(full.Worktrees) != 2 { + t.Fatalf("FromTaskSession Worktrees len = %d, want 2", len(full.Worktrees)) + } + if full.WorktreePath != "/x/a" { + t.Fatalf("WorktreePath = %q, want /x/a (first worktree)", full.WorktreePath) + } + + summary := FromTaskSessionSummary(session) + if len(summary.Worktrees) != 2 { + t.Fatalf("FromTaskSessionSummary Worktrees len = %d, want 2", len(summary.Worktrees)) + } + if summary.Worktrees[1].WorktreeID != "wt-2" { + t.Fatalf("second worktree id = %q, want wt-2", summary.Worktrees[1].WorktreeID) + } +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 47e3cc8315..805394ebe0 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -196,29 +196,33 @@ type TaskSessionDTO struct { TaskID string `json:"task_id"` // Name is the user-supplied session tab label. Serialized without // omitempty so a cleared name ("") overwrites stale client state. - Name string `json:"name"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` - ContainerID string `json:"container_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - ExecutorProfileID string `json:"executor_profile_id,omitempty"` - EnvironmentID string `json:"environment_id,omitempty"` - RepositoryID string `json:"repository_id,omitempty"` - BaseBranch string `json:"base_branch,omitempty"` - BaseCommitSHA string `json:"base_commit_sha,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` - State models.TaskSessionState `json:"state"` - ErrorMessage string `json:"error_message,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` - ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` - EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` - RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + EnvironmentID string `json:"environment_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + BaseCommitSHA string `json:"base_commit_sha,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + // Worktrees lists all session worktrees (one per repo on multi-repo tasks); + // the flattened Worktree* fields above carry only the first for + // backward compatibility. + Worktrees []*models.TaskSessionWorktree `json:"worktrees,omitempty"` + State models.TaskSessionState `json:"state"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"` + ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"` + EnvironmentSnapshot map[string]interface{} `json:"environment_snapshot,omitempty"` + RepositorySnapshot map[string]interface{} `json:"repository_snapshot,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` // Workflow fields IsPrimary bool `json:"is_primary"` IsPassthrough bool `json:"is_passthrough"` @@ -233,29 +237,33 @@ type TaskSessionSummaryDTO struct { TaskID string `json:"task_id"` // Name is the user-supplied session tab label. Serialized without // omitempty so a cleared name ("") overwrites stale client state. - Name string `json:"name"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` - ContainerID string `json:"container_id,omitempty"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - ExecutorID string `json:"executor_id,omitempty"` - ExecutorProfileID string `json:"executor_profile_id,omitempty"` - EnvironmentID string `json:"environment_id,omitempty"` - RepositoryID string `json:"repository_id,omitempty"` - BaseBranch string `json:"base_branch,omitempty"` - BaseCommitSHA string `json:"base_commit_sha,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` - State models.TaskSessionState `json:"state"` - ErrorMessage string `json:"error_message,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - StartedAt time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - IsPrimary bool `json:"is_primary"` - IsPassthrough bool `json:"is_passthrough"` - ReviewStatus models.ReviewStatus `json:"review_status,omitempty"` - TaskEnvironmentID string `json:"task_environment_id,omitempty"` + Name string `json:"name"` + AgentExecutionID string `json:"agent_execution_id,omitempty"` + ContainerID string `json:"container_id,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` + EnvironmentID string `json:"environment_id,omitempty"` + RepositoryID string `json:"repository_id,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + BaseCommitSHA string `json:"base_commit_sha,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + WorktreeBranch string `json:"worktree_branch,omitempty"` + // Worktrees lists all session worktrees (one per repo on multi-repo tasks); + // the flattened Worktree* fields above carry only the first for + // backward compatibility. + Worktrees []*models.TaskSessionWorktree `json:"worktrees,omitempty"` + State models.TaskSessionState `json:"state"` + ErrorMessage string `json:"error_message,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + IsPrimary bool `json:"is_primary"` + IsPassthrough bool `json:"is_passthrough"` + ReviewStatus models.ReviewStatus `json:"review_status,omitempty"` + TaskEnvironmentID string `json:"task_environment_id,omitempty"` // CommandCount is the number of tool_call messages on this session, // surfaced inline in the timeline entry header ("ran N commands"). // Populated by ListTaskSessions; defaults to 0 for callers that don't @@ -662,6 +670,7 @@ func FromTaskSessionSummary(session *models.TaskSession) TaskSessionSummaryDTO { result.WorktreeID = session.Worktrees[0].WorktreeID result.WorktreePath = session.Worktrees[0].WorktreePath result.WorktreeBranch = session.Worktrees[0].WorktreeBranch + result.Worktrees = session.Worktrees } return result } @@ -700,6 +709,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { result.WorktreeID = session.Worktrees[0].WorktreeID result.WorktreePath = session.Worktrees[0].WorktreePath result.WorktreeBranch = session.Worktrees[0].WorktreeBranch + result.Worktrees = session.Worktrees } return result } diff --git a/apps/web/components/task/editor-worktree-options.test.ts b/apps/web/components/task/editor-worktree-options.test.ts new file mode 100644 index 0000000000..6cca74076a --- /dev/null +++ b/apps/web/components/task/editor-worktree-options.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildWorktreeOptions } from "./editor-worktree-options"; +import type { Worktree } from "@/lib/state/slices/session/types"; +import type { Repository } from "@/lib/types/http"; + +function repo(id: string, name: string): Repository { + return { id, name } as Repository; +} + +describe("buildWorktreeOptions", () => { + it("labels worktrees with their repository name", () => { + const worktrees: Worktree[] = [ + { id: "wt-1", sessionId: "s-1", repositoryId: "repo-a", path: "/tasks/x/a", branch: "kd/x" }, + { id: "wt-2", sessionId: "s-1", repositoryId: "repo-b", path: "/tasks/x/b", branch: "kd/y" }, + ]; + const options = buildWorktreeOptions(worktrees, [repo("repo-a", "api"), repo("repo-b", "web")]); + expect(options).toEqual([ + { worktreeId: "wt-1", label: "api", branch: "kd/x" }, + { worktreeId: "wt-2", label: "web", branch: "kd/y" }, + ]); + }); + + it("falls back to the worktree folder name when the repository is unknown", () => { + const worktrees: Worktree[] = [ + { id: "wt-1", sessionId: "s-1", repositoryId: "missing", path: "/tasks/x/kandev" }, + ]; + const options = buildWorktreeOptions(worktrees, []); + expect(options).toEqual([{ worktreeId: "wt-1", label: "kandev", branch: undefined }]); + }); + + it("handles Windows-style paths", () => { + const worktrees: Worktree[] = [{ id: "wt-1", sessionId: "s-1", path: "C:\\tasks\\x\\kandev" }]; + expect(buildWorktreeOptions(worktrees, [])[0].label).toBe("kandev"); + }); + + it("falls back to a positional label without repository or path", () => { + const worktrees: Worktree[] = [ + { id: "wt-1", sessionId: "s-1" }, + { id: "wt-2", sessionId: "s-1" }, + ]; + const options = buildWorktreeOptions(worktrees, []); + expect(options.map((o) => o.label)).toEqual(["Worktree 1", "Worktree 2"]); + }); +}); diff --git a/apps/web/components/task/editor-worktree-options.ts b/apps/web/components/task/editor-worktree-options.ts new file mode 100644 index 0000000000..04ca065632 --- /dev/null +++ b/apps/web/components/task/editor-worktree-options.ts @@ -0,0 +1,29 @@ +import type { Worktree } from "@/lib/state/slices/session/types"; +import type { Repository } from "@/lib/types/http"; + +export type WorktreeOption = { + worktreeId: string; + label: string; + branch?: string; +}; + +function worktreePathBasename(path: string | undefined): string | undefined { + if (!path) return undefined; + return path.split(/[\\/]/).filter(Boolean).pop(); +} + +/** + * Builds display options for a session's worktrees: labeled by repository name + * when known, otherwise by the worktree folder name. + */ +export function buildWorktreeOptions( + worktrees: Worktree[], + repositories: Repository[], +): WorktreeOption[] { + return worktrees.map((worktree, index) => { + const repository = repositories.find((repo) => repo.id === worktree.repositoryId); + const label = + repository?.name ?? worktreePathBasename(worktree.path) ?? `Worktree ${index + 1}`; + return { worktreeId: worktree.id, label, branch: worktree.branch }; + }); +} diff --git a/apps/web/components/task/editors-menu.tsx b/apps/web/components/task/editors-menu.tsx index 5989337378..80b3683d0e 100644 --- a/apps/web/components/task/editors-menu.tsx +++ b/apps/web/components/task/editors-menu.tsx @@ -7,22 +7,171 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, 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 { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees"; import { useAppStore } from "@/components/state-provider"; import type { EditorOption } from "@/lib/types/http"; +import { + buildWorktreeOptions, + type WorktreeOption, +} from "@/components/task/editor-worktree-options"; + +const menuItemClass = "cursor-pointer"; type EditorsMenuProps = { activeSessionId: string | null; }; +function useWorktreeOptions(sessionId: string | null): WorktreeOption[] { + const worktrees = useSessionWorktrees(sessionId); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + return useMemo( + () => buildWorktreeOptions(worktrees, Object.values(repositoriesByWorkspace).flat()), + [worktrees, repositoriesByWorkspace], + ); +} + +function WorktreeItems({ + options, + onSelect, +}: { + options: WorktreeOption[]; + onSelect: (worktreeId: string) => void; +}) { + return ( + <> + {options.map((option) => ( + onSelect(option.worktreeId)} + > + {option.label} + {option.branch && ( + + {option.branch} + + )} + + ))} + + ); +} + +function OpenEditorButton({ + disabled, + isLoading, + tooltip, + worktreeOptions, + onOpen, +}: { + disabled: boolean; + isLoading: boolean; + tooltip: string; + worktreeOptions: WorktreeOption[]; + onOpen: (worktreeId?: string) => void; +}) { + const icon = isLoading ? ( + + ) : ( + + ); + const buttonClass = "rounded-none border-0 cursor-pointer px-2 focus-visible:ring-inset"; + + // Multi-worktree sessions get a picker instead of opening the first worktree. + // The focusable span keeps the tooltip reachable while the button is + // disabled (disabled buttons receive no pointer/focus events). + if (worktreeOptions.length > 1) { + return ( + + + + + + + + + + {tooltip} + + + onOpen(worktreeId)} /> + + + ); + } + + return ( + + + + + + + {tooltip} + + ); +} + +function EditorMenuEntry({ + editor, + worktreeOptions, + onOpen, +}: { + editor: EditorOption; + worktreeOptions: WorktreeOption[]; + onOpen: (editorId: string, worktreeId?: string) => void; +}) { + if (worktreeOptions.length > 1) { + return ( + + {editor.name} + + onOpen(editor.id, worktreeId)} + /> + + + ); + } + return ( + onOpen(editor.id)}> + {editor.name} + + ); +} + export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { const openEditor = useOpenSessionInEditor(activeSessionId ?? null); const { editors } = useEditors(); const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId); + const worktreeOptions = useWorktreeOptions(activeSessionId ?? null); const enabledEditors = useMemo( () => @@ -44,37 +193,27 @@ export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { return enabledEditors[0]?.id ?? ""; }, [defaultEditorId, enabledEditors]); + const openWith = (editorId: string, worktreeId?: string) => { + if (!editorId) return; + void openEditor.open({ editorId, worktreeId }); + }; + return (
- - - - - - {activeSessionId ? "Open editor" : "Select a session to open its worktree"} - - + openWith(resolvedEditorId, worktreeId)} + />