From 9b826363f3628ba9a44428dc31a09940ffc53916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 3 Feb 2026 22:07:41 +0000 Subject: [PATCH] fix: db path + open workspace folder (#169) --- .../internal/editors/controller/controller.go | 7 + apps/backend/internal/editors/dto/dto.go | 4 + .../internal/editors/handlers/handlers.go | 11 + .../internal/editors/service/service.go | 32 ++ .../internal/editors/service/service_test.go | 502 ++++++++++++++++++ apps/cli/src/start.ts | 8 +- apps/packages/ui/src/pannel-session.tsx | 7 +- apps/web/components/task/task-top-bar.tsx | 27 +- apps/web/hooks/use-open-session-folder.ts | 20 + apps/web/lib/api/domains/session-api.ts | 7 + 10 files changed, 610 insertions(+), 15 deletions(-) create mode 100644 apps/backend/internal/editors/service/service_test.go create mode 100644 apps/web/hooks/use-open-session-folder.ts diff --git a/apps/backend/internal/editors/controller/controller.go b/apps/backend/internal/editors/controller/controller.go index 8fa6376956..d187042815 100644 --- a/apps/backend/internal/editors/controller/controller.go +++ b/apps/backend/internal/editors/controller/controller.go @@ -70,6 +70,13 @@ 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 { + return dto.OpenFolderResponse{Success: false}, err + } + return dto.OpenFolderResponse{Success: true}, 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, diff --git a/apps/backend/internal/editors/dto/dto.go b/apps/backend/internal/editors/dto/dto.go index 2951a75220..d95e858cb1 100644 --- a/apps/backend/internal/editors/dto/dto.go +++ b/apps/backend/internal/editors/dto/dto.go @@ -69,3 +69,7 @@ type UpdateEditorRequest struct { Config json.RawMessage `json:"config,omitempty"` Enabled *bool `json:"enabled,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 de97bdd8d5..2ccb98516e 100644 --- a/apps/backend/internal/editors/handlers/handlers.go +++ b/apps/backend/internal/editors/handlers/handlers.go @@ -31,6 +31,7 @@ func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger api.PATCH("/editors/:id", handlers.httpUpdateEditor) api.DELETE("/editors/:id", handlers.httpDeleteEditor) api.POST("/task-sessions/:id/open-editor", handlers.httpOpenSessionEditor) + api.POST("/task-sessions/:id/open-folder", handlers.httpOpenFolder) } func (h *Handlers) httpListEditors(c *gin.Context) { @@ -96,3 +97,13 @@ func (h *Handlers) httpDeleteEditor(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"success": true}) } + +func (h *Handlers) httpOpenFolder(c *gin.Context) { + resp, err := h.controller.OpenFolder(c.Request.Context(), c.Param("id")) + if err != nil { + h.logger.Error("failed to open folder", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open folder"}) + return + } + c.JSON(http.StatusOK, resp) +} diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go index c535900f83..f58431df0b 100644 --- a/apps/backend/internal/editors/service/service.go +++ b/apps/backend/internal/editors/service/service.go @@ -8,6 +8,7 @@ import ( "net/url" "os/exec" "path/filepath" + "runtime" "strconv" "strings" "time" @@ -129,6 +130,37 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string } } +func (s *Service) OpenFolder(ctx context.Context, sessionID string) error { + if sessionID == "" { + return ErrEditorConfigInvalid + } + session, err := s.taskRepo.GetTaskSession(ctx, sessionID) + if err != nil { + return err + } + worktreePath, err := s.resolveSessionPath(ctx, session) + if err != nil { + return err + } + + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", worktreePath) + case "linux": + cmd = exec.Command("xdg-open", worktreePath) + case "windows": + cmd = exec.Command("explorer", worktreePath) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open folder: %w", err) + } + return nil +} + func (s *Service) resolveEditor(ctx context.Context, editorID, editorType, fallbackID string) (*models.Editor, error) { if editorID == "" { editorID = strings.TrimSpace(fallbackID) diff --git a/apps/backend/internal/editors/service/service_test.go b/apps/backend/internal/editors/service/service_test.go new file mode 100644 index 0000000000..275a6451a5 --- /dev/null +++ b/apps/backend/internal/editors/service/service_test.go @@ -0,0 +1,502 @@ +package service + +import ( + "context" + "testing" + + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +func TestOpenFolder_EmptySessionID(t *testing.T) { + svc := &Service{} + err := svc.OpenFolder(context.Background(), "") + if err != ErrEditorConfigInvalid { + t.Errorf("expected ErrEditorConfigInvalid, got %v", err) + } +} + +func TestResolveSessionPath_NilSession(t *testing.T) { + svc := &Service{} + _, err := svc.resolveSessionPath(context.Background(), nil) + if err != ErrWorkspaceNotFound { + t.Errorf("expected ErrWorkspaceNotFound, got %v", err) + } +} + +func TestResolveSessionPath_WithWorktree(t *testing.T) { + svc := &Service{} + session := &taskmodels.TaskSession{ + ID: "session-1", + Worktrees: []*taskmodels.TaskSessionWorktree{ + {WorktreePath: "/path/to/worktree"}, + }, + } + path, err := svc.resolveSessionPath(context.Background(), session) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "/path/to/worktree" { + t.Errorf("expected /path/to/worktree, got %s", path) + } +} + +func TestResolveSessionPath_EmptyWorktreePath(t *testing.T) { + svc := &Service{} + session := &taskmodels.TaskSession{ + ID: "session-1", + Worktrees: []*taskmodels.TaskSessionWorktree{ + {WorktreePath: ""}, + }, + } + _, err := svc.resolveSessionPath(context.Background(), session) + if err != ErrWorkspaceNotFound { + t.Errorf("expected ErrWorkspaceNotFound, got %v", err) + } +} + +func TestResolveFilePath(t *testing.T) { + svc := &Service{} + + tests := []struct { + name string + worktreePath string + filePath string + expected string + expectErr error + }{ + { + name: "empty worktree path", + worktreePath: "", + filePath: "file.txt", + expected: "", + expectErr: ErrWorkspaceNotFound, + }, + { + name: "empty file path returns worktree", + worktreePath: "/workspace", + filePath: "", + expected: "/workspace", + expectErr: nil, + }, + { + name: "valid file path", + worktreePath: "/workspace", + filePath: "src/main.go", + expected: "/workspace/src/main.go", + expectErr: nil, + }, + { + name: "path traversal blocked", + worktreePath: "/workspace", + filePath: "../etc/passwd", + expected: "", + expectErr: ErrEditorConfigInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := svc.resolveFilePath(tt.worktreePath, tt.filePath) + if err != tt.expectErr { + t.Errorf("expected error %v, got %v", tt.expectErr, err) + } + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestBuildRemoteSSHURL(t *testing.T) { + tests := []struct { + name string + scheme string + user string + host string + path string + line int + column int + expected string + }{ + { + name: "basic", + scheme: "vscode", + user: "", + host: "myhost", + path: "/home/user/file.txt", + line: 0, + column: 0, + expected: "vscode://vscode-remote/ssh-remote+myhost:/home/user/file.txt", + }, + { + name: "with user", + scheme: "vscode", + user: "admin", + host: "myhost", + path: "/home/user/file.txt", + line: 0, + column: 0, + expected: "vscode://vscode-remote/ssh-remote+admin@myhost:/home/user/file.txt", + }, + { + name: "with line", + scheme: "vscode", + user: "", + host: "myhost", + path: "/home/user/file.txt", + line: 10, + column: 0, + expected: "vscode://vscode-remote/ssh-remote+myhost:/home/user/file.txt:10", + }, + { + name: "with line and column", + scheme: "vscode", + user: "", + host: "myhost", + path: "/home/user/file.txt", + line: 10, + column: 5, + expected: "vscode://vscode-remote/ssh-remote+myhost:/home/user/file.txt:10:5", + }, + { + name: "default scheme", + scheme: "", + user: "", + host: "myhost", + path: "/file.txt", + line: 0, + column: 0, + expected: "vscode://vscode-remote/ssh-remote+myhost:/file.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildRemoteSSHURL(tt.scheme, tt.user, tt.host, tt.path, tt.line, tt.column) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestBuildHostedURL(t *testing.T) { + tests := []struct { + name string + baseURL string + path string + line int + column int + expected string + expectErr bool + }{ + { + name: "basic with path", + baseURL: "https://code.example.com", + path: "/home/user/file.txt", + line: 0, + column: 0, + expected: "https://code.example.com?file=%2Fhome%2Fuser%2Ffile.txt", + }, + { + name: "empty path", + baseURL: "https://code.example.com", + path: "", + line: 0, + column: 0, + expected: "https://code.example.com?folder=%2F", + }, + { + name: "with line", + baseURL: "https://code.example.com", + path: "/file.txt", + line: 10, + column: 0, + expected: "https://code.example.com?file=%2Ffile.txt%3A10", + }, + { + name: "with line and column", + baseURL: "https://code.example.com", + path: "/file.txt", + line: 10, + column: 5, + expected: "https://code.example.com?file=%2Ffile.txt%3A10%3A5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := buildHostedURL(tt.baseURL, tt.path, tt.line, tt.column) + if tt.expectErr && err == nil { + t.Error("expected error but got none") + } + if !tt.expectErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestBuildLocalArgs(t *testing.T) { + tests := []struct { + name string + editorType string + path string + line int + column int + expected []string + }{ + { + name: "empty path", + editorType: "vscode", + path: "", + line: 0, + column: 0, + expected: nil, + }, + { + name: "vscode without line", + editorType: "vscode", + path: "/file.txt", + line: 0, + column: 0, + expected: []string{"/file.txt"}, + }, + { + name: "vscode with line", + editorType: "vscode", + path: "/file.txt", + line: 10, + column: 0, + expected: []string{"--goto", "/file.txt:10"}, + }, + { + name: "vscode with line and column", + editorType: "vscode", + path: "/file.txt", + line: 10, + column: 5, + expected: []string{"--goto", "/file.txt:10:5"}, + }, + { + name: "cursor with line", + editorType: "cursor", + path: "/file.txt", + line: 10, + column: 0, + expected: []string{"--goto", "/file.txt:10"}, + }, + { + name: "other editor", + editorType: "sublime", + path: "/file.txt", + line: 10, + column: 5, + expected: []string{"/file.txt"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildLocalArgs(tt.editorType, tt.path, tt.line, tt.column) + if len(result) != len(tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, result) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("expected %v, got %v", tt.expected, result) + return + } + } + }) + } +} + +func TestExpandCommandPlaceholders(t *testing.T) { + tests := []struct { + name string + template string + worktreePath string + absPath string + line int + column int + expected string + }{ + { + name: "all placeholders", + template: "code {cwd} {file} {rel} {line} {column}", + worktreePath: "/workspace", + absPath: "/workspace/src/main.go", + line: 10, + column: 5, + expected: "code /workspace /workspace/src/main.go src/main.go 10 5", + }, + { + name: "no placeholders", + template: "code --new-window", + worktreePath: "/workspace", + absPath: "/workspace/file.txt", + line: 0, + column: 0, + expected: "code --new-window", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandCommandPlaceholders(tt.template, tt.worktreePath, tt.absPath, tt.line, tt.column) + if result != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestIsCustomKind(t *testing.T) { + tests := []struct { + kind string + expected bool + }{ + {"custom_command", true}, + {"custom_remote_ssh", true}, + {"custom_hosted_url", true}, + {"vscode", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + result := isCustomKind(tt.kind) + if result != tt.expected { + t.Errorf("expected %v for kind %q, got %v", tt.expected, tt.kind, result) + } + }) + } +} + +func TestParseCommandConfig(t *testing.T) { + tests := []struct { + name string + data string + expectErr bool + }{ + { + name: "valid", + data: `{"command": "code --new-window"}`, + expectErr: false, + }, + { + name: "empty command", + data: `{"command": ""}`, + expectErr: true, + }, + { + name: "whitespace command", + data: `{"command": " "}`, + expectErr: true, + }, + { + name: "empty data", + data: "", + expectErr: true, + }, + { + name: "invalid json", + data: "not json", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseCommandConfig([]byte(tt.data)) + if tt.expectErr && err == nil { + t.Error("expected error but got none") + } + if !tt.expectErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestParseRemoteSSHConfig(t *testing.T) { + tests := []struct { + name string + data string + expectErr bool + }{ + { + name: "valid with host", + data: `{"host": "myserver.com"}`, + expectErr: false, + }, + { + name: "valid with user and host", + data: `{"host": "myserver.com", "user": "admin"}`, + expectErr: false, + }, + { + name: "empty host", + data: `{"host": ""}`, + expectErr: true, + }, + { + name: "invalid json", + data: "not json", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseRemoteSSHConfig([]byte(tt.data)) + if tt.expectErr && err == nil { + t.Error("expected error but got none") + } + if !tt.expectErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestParseHostedURLConfig(t *testing.T) { + tests := []struct { + name string + data string + expectErr bool + }{ + { + name: "valid", + data: `{"url": "https://code.example.com"}`, + expectErr: false, + }, + { + name: "empty url", + data: `{"url": ""}`, + expectErr: true, + }, + { + name: "invalid json", + data: "not json", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseHostedURLConfig([]byte(tt.data)) + if tt.expectErr && err == nil { + t.Error("expected error but got none") + } + if !tt.expectErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index eca48c3d7e..4607a56f55 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -84,7 +84,12 @@ export async function runStart({ } // Production mode: use warn log level for clean output unless verbose - const backendEnv = buildBackendEnv({ ports, logLevel: verbose ? "info" : "warn" }); + const dbPath = path.join(DATA_DIR, "kandev.db"); + const backendEnv = buildBackendEnv({ + ports, + logLevel: verbose ? "info" : "warn", + extra: { KANDEV_DB_PATH: dbPath }, + }); const webEnv = buildWebEnv({ ports, includeMcp: true, production: true }); const supervisor = createProcessSupervisor(); @@ -122,7 +127,6 @@ export async function runStart({ await waitForHealth(ports.backendUrl, backendProc, healthTimeoutMs); // Print clean summary - const dbPath = path.join(DATA_DIR, "kandev.db"); console.log(""); console.log("[kandev] Server started successfully"); console.log(""); diff --git a/apps/packages/ui/src/pannel-session.tsx b/apps/packages/ui/src/pannel-session.tsx index b24461adb4..a9b7034772 100644 --- a/apps/packages/ui/src/pannel-session.tsx +++ b/apps/packages/ui/src/pannel-session.tsx @@ -28,10 +28,7 @@ export function SessionPanel({
( function SessionPanelContent({ children, className, wrapperClassName }, ref) { return ( -
+
{/* More Options (3-dot) Dropdown */} - + - @@ -560,18 +567,22 @@ const TaskTopBar = memo(function TaskTopBar({ Copy workspace path - {/* TODO: Implement open workspace folder { - // Open workspace folder in system file manager - }} - disabled={!worktreePath} + onClick={() => openFolder()} + disabled={!activeSessionId || isOpenFolderLoading} > Open workspace folder - */} + + router.push('/settings/general')} + > + + Open Settings +
diff --git a/apps/web/hooks/use-open-session-folder.ts b/apps/web/hooks/use-open-session-folder.ts new file mode 100644 index 0000000000..37b4b3a2d2 --- /dev/null +++ b/apps/web/hooks/use-open-session-folder.ts @@ -0,0 +1,20 @@ +'use client'; + +import { openSessionFolder } from '@/lib/api'; +import { useRequest } from '@/lib/http/use-request'; + +export function useOpenSessionFolder(sessionId?: string | null) { + const request = useRequest(async () => { + if (!sessionId) { + return null; + } + const response = await openSessionFolder(sessionId, { cache: 'no-store' }); + return response ?? null; + }); + + return { + open: () => request.run(), + status: request.status, + isLoading: request.isLoading, + }; +} diff --git a/apps/web/lib/api/domains/session-api.ts b/apps/web/lib/api/domains/session-api.ts index 675390886e..a4036c86d0 100644 --- a/apps/web/lib/api/domains/session-api.ts +++ b/apps/web/lib/api/domains/session-api.ts @@ -39,3 +39,10 @@ export async function openSessionInEditor( init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) }, }); } + +export async function openSessionFolder(sessionId: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/task-sessions/${sessionId}/open-folder`, { + ...options, + init: { method: 'POST', ...(options?.init ?? {}) }, + }); +}