diff --git a/apps/backend/internal/agent/runtime/agentctl/client_workspace_preview.go b/apps/backend/internal/agent/runtime/agentctl/client_workspace_preview.go new file mode 100644 index 0000000000..04b998812f --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/client_workspace_preview.go @@ -0,0 +1,63 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" +) + +const MaxWorkspacePreviewContentBytes = agentctltypes.MaxWorkspacePreviewContentBytes + +// WorkspacePreviewRequest is the current editor buffer to publish through +// agentctl. +type WorkspacePreviewRequest = agentctltypes.WorkspacePreviewRequest + +// WorkspacePreviewResponse identifies the live agentctl preview server and +// the published entry document. +type WorkspacePreviewResponse = agentctltypes.WorkspacePreviewResponse + +// WorkspacePreviewHTTPError is returned when agentctl rejects a publish. +type WorkspacePreviewHTTPError = agentctltypes.WorkspacePreviewHTTPError + +// PublishWorkspacePreview publishes one current HTML editor buffer. +func (c *Client) PublishWorkspacePreview(ctx context.Context, payload WorkspacePreviewRequest) (WorkspacePreviewResponse, error) { + if len([]byte(payload.Content)) > MaxWorkspacePreviewContentBytes { + return WorkspacePreviewResponse{}, fmt.Errorf("workspace preview content exceeds 5 MiB") + } + body, err := json.Marshal(payload) + if err != nil { + return WorkspacePreviewResponse{}, fmt.Errorf("marshal workspace preview request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/html-previews", bytes.NewReader(body)) + if err != nil { + return WorkspacePreviewResponse{}, fmt.Errorf("create workspace preview request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return WorkspacePreviewResponse{}, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, resp.Body) + return WorkspacePreviewResponse{}, &WorkspacePreviewHTTPError{Status: resp.StatusCode} + } + responseBody, err := readResponseBody(resp) + if err != nil { + return WorkspacePreviewResponse{}, fmt.Errorf("read workspace preview response: %w", err) + } + var result WorkspacePreviewResponse + if err := json.Unmarshal(responseBody, &result); err != nil { + return WorkspacePreviewResponse{}, fmt.Errorf("parse workspace preview response: %w", err) + } + if result.Port < 1024 || result.Port > 65535 || result.Version == 0 || !strings.HasPrefix(result.Path, "/") { + return WorkspacePreviewResponse{}, fmt.Errorf("invalid workspace preview response") + } + return result, nil +} diff --git a/apps/backend/internal/agent/runtime/agentctl/client_workspace_preview_test.go b/apps/backend/internal/agent/runtime/agentctl/client_workspace_preview_test.go new file mode 100644 index 0000000000..b105b7e5d5 --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/client_workspace_preview_test.go @@ -0,0 +1,120 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPublishWorkspacePreviewForwardsCurrentBufferAndValidatesResponse(t *testing.T) { + var got WorkspacePreviewRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/html-previews" { + t.Errorf("request = %s %s, want POST /api/v1/workspace/html-previews", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"port":43127,"path":"/site/index.html","version":4}`)) + })) + t.Cleanup(srv.Close) + host, port := splitTestServerHostPort(t, srv) + client := NewClient(host, port, newTestLogger()) + + response, err := client.PublishWorkspacePreview(context.Background(), WorkspacePreviewRequest{ + Repo: "frontend", + Path: "site/index.html", + Content: "", + }) + if err != nil { + t.Fatalf("PublishWorkspacePreview: %v", err) + } + if got.Repo != "frontend" || got.Path != "site/index.html" || !strings.Contains(got.Content, "dataset.ready") { + t.Fatalf("request = %+v, want repository, path, and current buffer", got) + } + if response.Port != 43127 || response.Path != "/site/index.html" || response.Version != 4 { + t.Fatalf("response = %+v, want validated server response", response) + } +} + +func TestPublishWorkspacePreviewRejectsOversizedContentBeforeNetwork(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + t.Cleanup(srv.Close) + host, port := splitTestServerHostPort(t, srv) + client := NewClient(host, port, newTestLogger()) + + _, err := client.PublishWorkspacePreview(context.Background(), WorkspacePreviewRequest{ + Path: "index.html", + Content: strings.Repeat("x", MaxWorkspacePreviewContentBytes+1), + }) + if err == nil { + t.Fatal("PublishWorkspacePreview accepted oversized content") + } + if called { + t.Fatal("PublishWorkspacePreview sent oversized content to agentctl") + } +} + +func TestPublishWorkspacePreviewPreservesRemoteStatusWithoutResponseBody(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte("source content must not escape the client error")) + })) + t.Cleanup(srv.Close) + host, port := splitTestServerHostPort(t, srv) + client := NewClient(host, port, newTestLogger()) + + _, err := client.PublishWorkspacePreview(context.Background(), WorkspacePreviewRequest{ + Path: "index.html", + Content: "current", + }) + if err == nil { + t.Fatal("PublishWorkspacePreview returned nil error") + } + var statusErr interface { + error + StatusCode() int + } + if !errors.As(err, &statusErr) { + t.Fatalf("error = %v, want typed status error", err) + } + if statusErr.StatusCode() != status { + t.Fatalf("status = %d, want %d", statusErr.StatusCode(), status) + } + if strings.Contains(err.Error(), "source content must not escape") { + t.Fatalf("error exposed upstream response body: %v", err) + } + }) + } +} + +func TestPublishWorkspacePreviewRejectsMalformedSuccessResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + t.Cleanup(srv.Close) + host, port := splitTestServerHostPort(t, srv) + client := NewClient(host, port, newTestLogger()) + + _, err := client.PublishWorkspacePreview(context.Background(), WorkspacePreviewRequest{ + Path: "index.html", + Content: "current", + }) + if err == nil { + t.Fatal("PublishWorkspacePreview accepted malformed success response") + } + if strings.Contains(err.Error(), "not-json") { + t.Fatalf("error exposed malformed response body: %v", err) + } +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 3fb15132b5..db78a0bb20 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -141,6 +141,7 @@ func (s *Server) setupRoutes() { api.GET("/workspace/file/content", s.handleFileContent) api.GET("/workspace/file/content-at-ref", s.handleFileContentAtRef) api.POST("/workspace/file/content", s.handleFileUpdate) + api.POST("/workspace/html-previews", s.handleWorkspacePreviewPublish) api.POST("/workspace/file/create", s.handleFileCreate) api.POST("/workspace/file/upload", s.handleFileUpload) api.POST("/workspace/file/upload-preflight", s.handleUploadPreflight) diff --git a/apps/backend/internal/agentctl/server/api/workspace_preview.go b/apps/backend/internal/agentctl/server/api/workspace_preview.go new file mode 100644 index 0000000000..d7311cbc8b --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_preview.go @@ -0,0 +1,70 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agentctl/server/process" + "go.uber.org/zap" +) + +const maxWorkspacePreviewRequestBytes = 32 * 1024 * 1024 + +type workspacePreviewPublishRequest struct { + Repo string `json:"repo,omitempty"` + Path string `json:"path"` + Content string `json:"content"` +} + +type workspacePreviewPublishResponse struct { + Port int `json:"port"` + Path string `json:"path"` + Version uint64 `json:"version"` +} + +func (s *Server) handleWorkspacePreviewPublish(c *gin.Context) { + if c.Request.ContentLength > maxWorkspacePreviewRequestBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "workspace preview request is too large"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxWorkspacePreviewRequestBytes) + var req workspacePreviewPublishRequest + if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "workspace preview request is too large"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace preview request"}) + return + } + + response, err := s.procMgr.PublishWorkspacePreview(process.WorkspacePreviewRequest{ + Repo: req.Repo, + Path: req.Path, + Content: req.Content, + }) + if err != nil { + switch { + case errors.Is(err, process.ErrWorkspacePreviewContentTooLarge): + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": err.Error()}) + case errors.Is(err, process.ErrWorkspacePreviewPathInvalid), + errors.Is(err, process.ErrWorkspacePreviewTypeUnsupported): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, process.ErrWorkspacePreviewUnavailable): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + default: + s.logger.Warn("workspace preview publish failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "workspace preview server unavailable"}) + } + return + } + + c.JSON(http.StatusOK, workspacePreviewPublishResponse{ + Port: response.Port, + Path: response.Path, + Version: response.Version, + }) +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_preview_test.go b/apps/backend/internal/agentctl/server/api/workspace_preview_test.go new file mode 100644 index 0000000000..7e3fddd71e --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_preview_test.go @@ -0,0 +1,347 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/common/logger" +) + +func newWorkspacePreviewServer(t *testing.T) (*Server, *process.Manager, string) { + t.Helper() + workDir := t.TempDir() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error"}) + cfg := &config.InstanceConfig{WorkDir: workDir} + mgr := process.NewManager(cfg, log) + t.Cleanup(func() { _ = mgr.CloseWorkspacePreview(context.Background()) }) + return NewServer(cfg, mgr, nil, nil, log), mgr, workDir +} + +func publishWorkspacePreview(t *testing.T, srv *Server, repo, path, content string) workspacePreviewPublishResponse { + t.Helper() + body, err := json.Marshal(map[string]string{ + "repo": repo, + "path": path, + "content": content, + }) + if err != nil { + t.Fatalf("marshal preview request: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/html-previews", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Router().ServeHTTP(rec, req) + var response workspacePreviewPublishResponse + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode preview response (%d): %v; body=%s", rec.Code, err, rec.Body.String()) + } + if rec.Code != http.StatusOK { + t.Fatalf("publish status = %d, response = %+v", rec.Code, response) + } + return response +} + +func getWorkspacePreview(t *testing.T, response workspacePreviewPublishResponse, path string) (*http.Response, string) { + t.Helper() + url := fmt.Sprintf("http://127.0.0.1:%d%s", response.Port, path) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatalf("build GET %s: %v", path, err) + } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read GET %s: %v", path, err) + } + return resp, string(body) +} + +func TestWorkspacePreviewPublishesOverlayAndServesWorkspaceAssets(t *testing.T) { + srv, mgr, workDir := newWorkspacePreviewServer(t) + if err := os.MkdirAll(filepath.Join(workDir, "site", "styles"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workDir, "site", "styles", "app.css"), []byte("body { color: red; }"), 0o644); err != nil { + t.Fatal(err) + } + + first := publishWorkspacePreview(t, srv, "", "site/index.html", "first") + if first.Port == 0 || first.Path != "/site/index.html" || first.Version != 1 { + t.Fatalf("first publish = %+v, want port, /site/index.html, version 1", first) + } + + entry, entryBody := getWorkspacePreview(t, first, first.Path) + if entry.StatusCode != http.StatusOK { + t.Fatalf("entry status = %d, want 200", entry.StatusCode) + } + if !strings.Contains(entry.Header.Get("Content-Type"), "text/html") { + t.Errorf("entry content type = %q, want text/html", entry.Header.Get("Content-Type")) + } + if entry.Header.Get("Cache-Control") != "no-store" { + t.Errorf("entry cache control = %q, want no-store", entry.Header.Get("Cache-Control")) + } + if !strings.Contains(entryBody, "first") { + t.Errorf("entry body = %q, want current overlay", entryBody) + } + + asset, assetBody := getWorkspacePreview(t, first, "/site/styles/app.css") + if asset.StatusCode != http.StatusOK || assetBody != "body { color: red; }" { + t.Fatalf("asset = %d %q, want workspace file", asset.StatusCode, assetBody) + } + if !strings.Contains(asset.Header.Get("Content-Type"), "text/css") { + t.Errorf("asset content type = %q, want text/css", asset.Header.Get("Content-Type")) + } + + second := publishWorkspacePreview(t, srv, "", "site/index.html", "second") + if second.Port != first.Port || second.Path != first.Path || second.Version <= first.Version { + t.Fatalf("replacement = %+v, want same port/path and increasing version after %+v", second, first) + } + updated, updatedBody := getWorkspacePreview(t, second, second.Path) + if updated.StatusCode != http.StatusOK || !strings.Contains(updatedBody, "second") { + t.Fatalf("updated entry = %d %q, want replacement overlay", updated.StatusCode, updatedBody) + } + + if err := mgr.StopForTeardown(t.Context()); err != nil { + t.Fatalf("stop manager: %v", err) + } + request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d%s", first.Port, first.Path), nil) + if err != nil { + t.Fatal(err) + } + if _, err := (&http.Client{}).Do(request); err == nil { + t.Fatal("preview server still accepted requests after manager teardown") + } +} + +func TestWorkspacePreviewRejectsEscapingPathsAndSymlinkTargets(t *testing.T) { + srv, _, workDir := newWorkspacePreviewServer(t) + outside := t.TempDir() + outsideFile := filepath.Join(outside, "secret.txt") + if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideFile, filepath.Join(workDir, "linked.txt")); err != nil { + t.Fatal(err) + } + + for _, path := range []string{"../secret.html", "site/../../secret.html", `%2e%2e/secret.html`} { + body, err := json.Marshal(map[string]string{"path": path, "content": "

blocked

"}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/html-previews", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Router().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("path %q status = %d, want 400", path, rec.Code) + } + } + + preview := publishWorkspacePreview(t, srv, "", "index.html", "") + linked, linkedBody := getWorkspacePreview(t, preview, "/linked.txt") + if linked.StatusCode == http.StatusOK || linkedBody == "secret" { + t.Fatalf("symlink target escaped workspace: status=%d body=%q", linked.StatusCode, linkedBody) + } +} + +func TestWorkspacePreviewEvictsLeastRecentlyPublishedOverlay(t *testing.T) { + srv, _, _ := newWorkspacePreviewServer(t) + responses := make([]workspacePreviewPublishResponse, 0, 33) + for i := 0; i < 33; i++ { + responses = append(responses, publishWorkspacePreview( + t, + srv, + "", + fmt.Sprintf("overlay-%02d.html", i), + fmt.Sprintf("

%02d

", i), + )) + } + + oldest, _ := getWorkspacePreview(t, responses[0], responses[0].Path) + if oldest.StatusCode != http.StatusNotFound { + t.Fatalf("oldest overlay status = %d, want 404 after 33 publishes", oldest.StatusCode) + } + newest, newestBody := getWorkspacePreview(t, responses[32], responses[32].Path) + if newest.StatusCode != http.StatusOK || !strings.Contains(newestBody, "32") { + t.Fatalf("newest overlay = %d %q, want retained content", newest.StatusCode, newestBody) + } +} + +func TestWorkspacePreviewRejectsOversizedEntryDocument(t *testing.T) { + srv, _, _ := newWorkspacePreviewServer(t) + content := strings.Repeat("x", 5*1024*1024+1) + body, err := json.Marshal(map[string]string{"path": "index.html", "content": content}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/html-previews", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Router().ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest { + t.Fatalf("oversized publish status = %d, want 413 or 400", rec.Code) + } +} + +// Reviewer coverage: the manager's lock must protect replacement publishes +// while static handlers read the currently published immutable buffer. +func TestWorkspacePreviewSupportsConcurrentPublishAndRead(t *testing.T) { + srv, _, _ := newWorkspacePreviewServer(t) + initial := publishWorkspacePreview(t, srv, "", "index.html", "

initial

") + control := httptest.NewServer(srv.Router()) + t.Cleanup(control.Close) + + const publishers = 12 + const readers = 12 + start := make(chan struct{}) + results := make(chan error, publishers+readers) + var wg sync.WaitGroup + for i := 0; i < publishers; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + <-start + _, err := postWorkspacePreview(control.URL, "", "index.html", fmt.Sprintf("

publish-%02d

", index)) + results <- err + }(i) + } + for i := 0; i < readers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d%s", initial.Port, initial.Path)) + if err != nil { + results <- err + return + } + defer func() { _ = resp.Body.Close() }() + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + results <- readErr + return + } + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "

") { + results <- fmt.Errorf("concurrent read = %d %q", resp.StatusCode, string(body)) + return + } + results <- nil + }() + } + close(start) + wg.Wait() + close(results) + for err := range results { + if err != nil { + t.Fatal(err) + } + } + + final, finalBody := getWorkspacePreview(t, initial, initial.Path) + if final.StatusCode != http.StatusOK || !strings.Contains(finalBody, "publish-") { + t.Fatalf("final preview = %d %q, want one concurrent replacement", final.StatusCode, finalBody) + } +} + +func TestWorkspacePreviewSupportsHeadMethodAndSeparateRepositoryRoots(t *testing.T) { + srv, _, workDir := newWorkspacePreviewServer(t) + for _, repo := range []string{"repo-a", "repo-b"} { + if err := os.Mkdir(filepath.Join(workDir, repo), 0o755); err != nil { + t.Fatal(err) + } + } + first := publishWorkspacePreview(t, srv, "repo-a", "index.html", "

repo-a

") + second := publishWorkspacePreview(t, srv, "repo-b", "index.html", "

repo-b

") + if first.Port == second.Port { + t.Fatalf("repository roots share port %d", first.Port) + } + for _, test := range []struct { + name string + response workspacePreviewPublishResponse + content string + }{ + {name: "repo-a", response: first, content: "repo-a"}, + {name: "repo-b", response: second, content: "repo-b"}, + } { + t.Run(test.name, func(t *testing.T) { + response, body := getWorkspacePreview(t, test.response, test.response.Path) + if response.StatusCode != http.StatusOK || !strings.Contains(body, test.content) { + t.Fatalf("GET = %d %q, want %s root content", response.StatusCode, body, test.content) + } + }) + } + + headURL := fmt.Sprintf("http://127.0.0.1:%d%s", first.Port, first.Path) + headRequest, err := http.NewRequest(http.MethodHead, headURL, nil) + if err != nil { + t.Fatal(err) + } + headResponse, err := (&http.Client{}).Do(headRequest) + if err != nil { + t.Fatal(err) + } + defer func() { _ = headResponse.Body.Close() }() + headBody, err := io.ReadAll(headResponse.Body) + if err != nil { + t.Fatal(err) + } + if headResponse.StatusCode != http.StatusOK || len(headBody) != 0 || headResponse.ContentLength <= 0 { + t.Fatalf("HEAD = %d length=%d body=%q, want 200 with empty body", headResponse.StatusCode, headResponse.ContentLength, headBody) + } + + methodRequest, err := http.NewRequest(http.MethodPost, headURL, strings.NewReader("ignored")) + if err != nil { + t.Fatal(err) + } + methodResponse, err := (&http.Client{}).Do(methodRequest) + if err != nil { + t.Fatal(err) + } + defer func() { _ = methodResponse.Body.Close() }() + if methodResponse.StatusCode != http.StatusMethodNotAllowed || methodResponse.Header.Get("Allow") != "GET, HEAD" { + t.Fatalf("POST = %d allow=%q, want 405 and GET, HEAD", methodResponse.StatusCode, methodResponse.Header.Get("Allow")) + } +} + +func postWorkspacePreview(baseURL, repo, path, content string) (workspacePreviewPublishResponse, error) { + body, err := json.Marshal(map[string]string{"repo": repo, "path": path, "content": content}) + if err != nil { + return workspacePreviewPublishResponse{}, err + } + request, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/workspace/html-previews", bytes.NewReader(body)) + if err != nil { + return workspacePreviewPublishResponse{}, err + } + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + return workspacePreviewPublishResponse{}, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return workspacePreviewPublishResponse{}, fmt.Errorf("publish status = %d", response.StatusCode) + } + var result workspacePreviewPublishResponse + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return workspacePreviewPublishResponse{}, err + } + return result, nil +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 2c5ec5bae1..a64cde64dd 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -206,6 +206,10 @@ type Manager struct { // Script/process runner (dev server, setup, cleanup, custom) processRunner *ProcessRunner + // workspacePreview owns ephemeral loopback static servers for current + // editor-buffer HTML previews. It is closed with the agentctl instance. + workspacePreview *WorkspacePreviewManager + // Embedded shell session (auto-created when agent starts) shell *shell.Session @@ -389,6 +393,7 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { false, ) m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes) + m.workspacePreview = NewWorkspacePreviewManager(log) m.shellMgr = shell.NewManager(cfg.WorkDir, log) m.status.Store(StatusStopped) m.exitCode.Store(-1) @@ -1167,6 +1172,53 @@ func (m *Manager) ResolveRepoSubdir(subpath string) (string, error) { return full, err } +// PublishWorkspacePreview publishes a current HTML editor buffer under the +// selected workspace or repository root. +func (m *Manager) PublishWorkspacePreview(req WorkspacePreviewRequest) (WorkspacePreviewResponse, error) { + if m.workspacePreview == nil { + return WorkspacePreviewResponse{}, ErrWorkspacePreviewUnavailable + } + root, err := m.resolveWorkspacePreviewRoot(req.Repo) + if err != nil { + return WorkspacePreviewResponse{}, err + } + return m.workspacePreview.Publish(root, req) +} + +// CloseWorkspacePreview stops all ephemeral preview servers owned by this +// agentctl process. +func (m *Manager) CloseWorkspacePreview(ctx context.Context) error { + if m.workspacePreview == nil { + return nil + } + return m.workspacePreview.Close(ctx) +} + +func (m *Manager) resolveWorkspacePreviewRoot(repo string) (string, error) { + _, root, err := m.resolveSubpath(repo) + if err != nil { + return "", err + } + canonicalRoot, err := canonicalPreviewDirectory(root) + if err != nil { + return "", fmt.Errorf("resolve preview root: %w", err) + } + canonicalWorkDir, err := canonicalPreviewDirectory(m.cfg.WorkDir) + if err != nil { + return "", fmt.Errorf("resolve workspace root: %w", err) + } + if previewPathWithinRoot(canonicalWorkDir, canonicalRoot) { + return canonicalRoot, nil + } + for _, sourceRoot := range m.currentWorkspaceSourceRoots() { + canonicalSourceRoot, sourceErr := canonicalPreviewDirectory(sourceRoot) + if sourceErr == nil && previewPathWithinRoot(canonicalSourceRoot, canonicalRoot) { + return canonicalRoot, nil + } + } + return "", fmt.Errorf("%w: preview root escapes workspace", ErrWorkspacePreviewPathInvalid) +} + // JoinRepoPath validates a repo subpath and returns the workspace-relative // path obtained by joining `subpath` and `path`. Empty `subpath` returns // `path` unchanged (single-repo workspaces). Used by file content / update @@ -1865,10 +1917,11 @@ func (m *Manager) Stop(ctx context.Context) error { // before stopping every process owned by the manager. func (m *Manager) StopForTeardown(ctx context.Context) error { m.CloseAdmission() + previewErr := m.CloseWorkspacePreview(ctx) if err := m.WaitForAdmission(ctx); err != nil { - return fmt.Errorf("wait for process admission to drain: %w", err) + return errors.Join(previewErr, fmt.Errorf("wait for process admission to drain: %w", err)) } - return m.stop(ctx) + return errors.Join(previewErr, m.stop(ctx)) } func (m *Manager) stop(ctx context.Context) error { diff --git a/apps/backend/internal/agentctl/server/process/workspace_preview.go b/apps/backend/internal/agentctl/server/process/workspace_preview.go new file mode 100644 index 0000000000..3c8b1b973a --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_preview.go @@ -0,0 +1,340 @@ +package process + +import ( + "context" + "errors" + "fmt" + "mime" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +const ( + // MaxWorkspacePreviewContentBytes bounds one current-buffer overlay. The + // shared agentctl type is the source of truth for every layer. + MaxWorkspacePreviewContentBytes = agentctltypes.MaxWorkspacePreviewContentBytes + maxWorkspacePreviewOverlays = 32 +) + +var ( + ErrWorkspacePreviewContentTooLarge = errors.New("workspace preview content exceeds 5 MiB") + ErrWorkspacePreviewPathInvalid = errors.New("workspace preview path is invalid") + ErrWorkspacePreviewTypeUnsupported = errors.New("workspace preview only supports HTML files") + ErrWorkspacePreviewUnavailable = errors.New("workspace preview server is unavailable") +) + +// WorkspacePreviewRequest is the current editor buffer to publish for a +// workspace preview. Repo is an optional repository-relative scope. +type WorkspacePreviewRequest struct { + Repo string + Path string + Content string +} + +// WorkspacePreviewResponse identifies the ephemeral static server and the +// published entry document. +type WorkspacePreviewResponse struct { + Port int + Path string + Version uint64 +} + +type workspacePreviewOverlay struct { + content string + version uint64 + published uint64 +} + +type workspacePreviewRoot struct { + path string + port int + server *http.Server + overlays map[string]workspacePreviewOverlay +} + +// WorkspacePreviewManager owns loopback static servers and in-memory entry +// overlays for one agentctl instance. +type WorkspacePreviewManager struct { + mu sync.Mutex + logger *logger.Logger + roots map[string]*workspacePreviewRoot + sequence uint64 + closed bool +} + +// NewWorkspacePreviewManager creates a manager for one agentctl instance. +func NewWorkspacePreviewManager(log *logger.Logger) *WorkspacePreviewManager { + return &WorkspacePreviewManager{ + logger: log.WithFields(zap.String("component", "workspace-preview")), + roots: make(map[string]*workspacePreviewRoot), + } +} + +// Publish stores an entry-document overlay and starts or reuses the static +// server for its selected workspace root. +func (m *WorkspacePreviewManager) Publish(root string, req WorkspacePreviewRequest) (WorkspacePreviewResponse, error) { + if len([]byte(req.Content)) > MaxWorkspacePreviewContentBytes { + return WorkspacePreviewResponse{}, ErrWorkspacePreviewContentTooLarge + } + relPath, err := validateWorkspacePreviewEntryPath(req.Path) + if err != nil { + return WorkspacePreviewResponse{}, err + } + canonicalRoot, err := canonicalPreviewDirectory(root) + if err != nil { + return WorkspacePreviewResponse{}, fmt.Errorf("resolve workspace preview root: %w", err) + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return WorkspacePreviewResponse{}, ErrWorkspacePreviewUnavailable + } + previewRoot := m.roots[canonicalRoot] + if previewRoot == nil { + previewRoot, err = m.startRootLocked(canonicalRoot) + if err != nil { + return WorkspacePreviewResponse{}, err + } + m.roots[canonicalRoot] = previewRoot + } + + previous := previewRoot.overlays[relPath] + version := previous.version + 1 + m.sequence++ + previewRoot.overlays[relPath] = workspacePreviewOverlay{ + content: req.Content, + version: version, + published: m.sequence, + } + m.evictOldestOverlayLocked() + m.logger.Info("workspace preview published", + zap.String("root", canonicalRoot), + zap.String("path", relPath), + zap.Int("bytes", len([]byte(req.Content))), + zap.Uint64("version", version), + ) + return WorkspacePreviewResponse{Port: previewRoot.port, Path: "/" + relPath, Version: version}, nil +} + +func (m *WorkspacePreviewManager) startRootLocked(root string) (*workspacePreviewRoot, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("start workspace preview listener: %w", err) + } + previewRoot := &workspacePreviewRoot{ + path: root, + port: listener.Addr().(*net.TCPAddr).Port, + overlays: make(map[string]workspacePreviewOverlay), + } + previewRoot.server = &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.serveRootRequest(previewRoot, w, r) + }), + } + go func() { + if serveErr := previewRoot.server.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + m.logger.Warn("workspace preview server stopped unexpectedly", + zap.String("root", root), zap.Int("port", previewRoot.port), zap.Error(serveErr)) + } + }() + m.logger.Info("workspace preview server started", + zap.String("root", root), zap.Int("port", previewRoot.port)) + return previewRoot, nil +} + +func (m *WorkspacePreviewManager) evictOldestOverlayLocked() { + count := 0 + var oldestRoot *workspacePreviewRoot + var oldestPath string + var oldestSequence uint64 + for _, root := range m.roots { + for path, overlay := range root.overlays { + count++ + if oldestRoot == nil || overlay.published < oldestSequence { + oldestRoot = root + oldestPath = path + oldestSequence = overlay.published + } + } + } + if count <= maxWorkspacePreviewOverlays || oldestRoot == nil { + return + } + delete(oldestRoot.overlays, oldestPath) + m.logger.Debug("workspace preview overlay evicted", + zap.String("root", oldestRoot.path), zap.String("path", oldestPath)) +} + +func (m *WorkspacePreviewManager) serveRootRequest(root *workspacePreviewRoot, w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + relPath, err := workspacePreviewRequestPath(r.URL) + if err != nil { + http.NotFound(w, r) + return + } + + m.mu.Lock() + overlay, hasOverlay := root.overlays[relPath] + m.mu.Unlock() + if hasOverlay { + serveWorkspacePreviewBytes(w, r, relPath, []byte(overlay.content)) + return + } + + filePath, err := resolveWorkspacePreviewFile(root.path, relPath) + if err != nil { + http.NotFound(w, r) + return + } + file, err := os.Open(filePath) + if err != nil { + http.NotFound(w, r) + return + } + defer func() { _ = file.Close() }() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + http.NotFound(w, r) + return + } + setWorkspacePreviewContentType(w, relPath) + http.ServeContent(w, r, filepath.Base(filePath), info.ModTime(), file) +} + +func serveWorkspacePreviewBytes(w http.ResponseWriter, r *http.Request, path string, content []byte) { + setWorkspacePreviewContentType(w, path) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(content) +} + +func setWorkspacePreviewContentType(w http.ResponseWriter, path string) { + if contentType := mime.TypeByExtension(filepath.Ext(path)); contentType != "" { + w.Header().Set("Content-Type", contentType) + return + } + w.Header().Set("Content-Type", "application/octet-stream") +} + +func resolveWorkspacePreviewFile(canonicalRoot, relPath string) (string, error) { + candidate := filepath.Join(canonicalRoot, filepath.FromSlash(relPath)) + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return "", err + } + if !previewPathWithinRoot(canonicalRoot, resolved) { + return "", ErrWorkspacePreviewPathInvalid + } + info, err := os.Stat(resolved) + if err != nil || !info.Mode().IsRegular() { + return "", os.ErrNotExist + } + return resolved, nil +} + +func workspacePreviewRequestPath(parsed *url.URL) (string, error) { + escaped := parsed.EscapedPath() + decoded, err := url.PathUnescape(escaped) + if err != nil || !strings.HasPrefix(decoded, "/") { + return "", ErrWorkspacePreviewPathInvalid + } + relPath := strings.TrimPrefix(decoded, "/") + if !validWorkspacePreviewRelativePath(relPath) { + return "", ErrWorkspacePreviewPathInvalid + } + return relPath, nil +} + +func validateWorkspacePreviewEntryPath(path string) (string, error) { + decodedPath, err := url.PathUnescape(path) + if err != nil { + return "", ErrWorkspacePreviewPathInvalid + } + path = decodedPath + if !validWorkspacePreviewRelativePath(path) { + return "", ErrWorkspacePreviewPathInvalid + } + ext := strings.ToLower(filepath.Ext(filepath.FromSlash(path))) + if ext != ".html" && ext != ".htm" { + return "", ErrWorkspacePreviewTypeUnsupported + } + return filepath.ToSlash(path), nil +} + +func validWorkspacePreviewRelativePath(path string) bool { + if path == "" || strings.ContainsRune(path, '\x00') || filepath.IsAbs(filepath.FromSlash(path)) { + return false + } + for _, segment := range strings.Split(filepath.ToSlash(path), "/") { + if segment == "" || segment == "." || segment == ".." { + return false + } + } + return true +} + +func canonicalPreviewDirectory(path string) (string, error) { + clean := filepath.Clean(path) + resolved, err := filepath.EvalSymlinks(clean) + if err != nil { + return "", err + } + info, err := os.Stat(resolved) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("preview root is not a directory") + } + return filepath.Clean(resolved), nil +} + +func previewPathWithinRoot(root, candidate string) bool { + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +// Close stops every preview server and releases its loopback listener. +func (m *WorkspacePreviewManager) Close(ctx context.Context) error { + m.mu.Lock() + if m.closed { + m.mu.Unlock() + return nil + } + m.closed = true + servers := make([]*http.Server, 0, len(m.roots)) + for _, root := range m.roots { + servers = append(servers, root.server) + } + m.roots = make(map[string]*workspacePreviewRoot) + m.mu.Unlock() + + var errs []error + for _, server := range servers { + if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + errs = append(errs, err) + _ = server.Close() + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} diff --git a/apps/backend/internal/agentctl/types/workspace_preview.go b/apps/backend/internal/agentctl/types/workspace_preview.go new file mode 100644 index 0000000000..c96b247a2b --- /dev/null +++ b/apps/backend/internal/agentctl/types/workspace_preview.go @@ -0,0 +1,36 @@ +package types + +import "fmt" + +const MaxWorkspacePreviewContentBytes = 5 * 1024 * 1024 + +// WorkspacePreviewRequest is the current editor buffer to publish through +// agentctl. The content is ephemeral and is not persisted by the client. +type WorkspacePreviewRequest struct { + Repo string `json:"repo,omitempty"` + Path string `json:"path"` + Content string `json:"content"` +} + +// WorkspacePreviewResponse identifies the live agentctl preview server and +// the published entry document. +type WorkspacePreviewResponse struct { + Port int `json:"port"` + Path string `json:"path"` + Version uint64 `json:"version"` +} + +// WorkspacePreviewHTTPError preserves an agentctl response status without +// retaining response content supplied by the workspace. +type WorkspacePreviewHTTPError struct { + Status int +} + +// StatusCode returns the upstream HTTP status for stable handler mapping. +func (e *WorkspacePreviewHTTPError) StatusCode() int { + return e.Status +} + +func (e *WorkspacePreviewHTTPError) Error() string { + return fmt.Sprintf("workspace preview publish failed with status %d", e.Status) +} diff --git a/apps/backend/internal/task/handlers/process_handlers.go b/apps/backend/internal/task/handlers/process_handlers.go index a6bf25de42..8b49537e95 100644 --- a/apps/backend/internal/task/handlers/process_handlers.go +++ b/apps/backend/internal/task/handlers/process_handlers.go @@ -68,6 +68,7 @@ func RegisterProcessRoutes( session.POST("/set-model", handlers.httpSetSessionModel) session.POST("/set-config-option", handlers.httpSetSessionConfigOption) session.POST("/authenticate", handlers.httpAuthenticate) + session.POST("/html-previews", handlers.httpPublishWorkspacePreview) return handlers } diff --git a/apps/backend/internal/task/handlers/session_scope_http_test.go b/apps/backend/internal/task/handlers/session_scope_http_test.go index 4aa0359101..f6a345bd33 100644 --- a/apps/backend/internal/task/handlers/session_scope_http_test.go +++ b/apps/backend/internal/task/handlers/session_scope_http_test.go @@ -108,6 +108,7 @@ func TestProcessRoutesDenyForeignSession(t *testing.T) { "set-model": h.httpSetSessionModel, "set-config-option": h.httpSetSessionConfigOption, "authenticate": h.httpAuthenticate, + "publish-preview": h.httpPublishWorkspacePreview, "stop-process": h.httpStopProcessByID, } for name, handler := range routes { diff --git a/apps/backend/internal/task/handlers/workspace_preview_handlers.go b/apps/backend/internal/task/handlers/workspace_preview_handlers.go new file mode 100644 index 0000000000..74e8686575 --- /dev/null +++ b/apps/backend/internal/task/handlers/workspace_preview_handlers.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "go.uber.org/zap" +) + +const maxWorkspacePreviewRequestBytes = 32 * 1024 * 1024 + +type httpWorkspacePreviewRequest struct { + Repo string `json:"repo,omitempty"` + Path string `json:"path"` + Content string `json:"content"` +} + +type workspacePreviewClient interface { + PublishWorkspacePreview( + context.Context, agentctltypes.WorkspacePreviewRequest, + ) (agentctltypes.WorkspacePreviewResponse, error) +} + +func (h *ProcessHandlers) httpPublishWorkspacePreview(c *gin.Context) { + sessionID := c.Param("id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"}) + return + } + if h.denySessionAccess(c, sessionID) { + return + } + if c.Request.ContentLength > maxWorkspacePreviewRequestBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "workspace preview request is too large"}) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxWorkspacePreviewRequestBytes) + var req httpWorkspacePreviewRequest + if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "workspace preview request is too large"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace preview request"}) + return + } + if len([]byte(req.Content)) > agentctltypes.MaxWorkspacePreviewContentBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "workspace preview content exceeds 5 MiB"}) + return + } + + session, err := h.service.GetTaskSession(c.Request.Context(), sessionID) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + if !h.ensureAgentctlReady(c, session, sessionID) { + return + } + + client, release, ok := h.workspacePreviewClient(c, sessionID) + if !ok { + return + } + defer release() + response, err := client.PublishWorkspacePreview(c.Request.Context(), agentctltypes.WorkspacePreviewRequest{ + Repo: req.Repo, + Path: req.Path, + Content: req.Content, + }) + if err != nil { + h.respondWorkspacePreviewPublishError(c, sessionID, err) + return + } + c.JSON(http.StatusOK, response) +} + +func (h *ProcessHandlers) respondWorkspacePreviewPublishError(c *gin.Context, sessionID string, err error) { + status := http.StatusBadGateway + message := "workspace preview publish failed" + var statusErr *agentctltypes.WorkspacePreviewHTTPError + if errors.As(err, &statusErr) { + switch statusErr.StatusCode() { + case http.StatusBadRequest: + status = http.StatusBadRequest + message = "invalid workspace preview request" + case http.StatusRequestEntityTooLarge: + status = http.StatusRequestEntityTooLarge + message = "workspace preview content exceeds 5 MiB" + case http.StatusServiceUnavailable: + status = http.StatusServiceUnavailable + message = "workspace preview unavailable" + } + } + h.logger.Warn("failed to publish workspace preview", zap.Error(err), zap.String("session_id", sessionID)) + c.JSON(status, gin.H{"error": message}) +} + +// workspacePreviewClient resolves the session's agentctl client, responding +// with 503 when the session has no live execution to publish into. +func (h *ProcessHandlers) workspacePreviewClient(c *gin.Context, sessionID string) (workspacePreviewClient, func(), bool) { + execution, err := h.lifecycleMgr.GetOrEnsureExecution(c.Request.Context(), sessionID) + if err != nil || execution == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agentctl not ready"}) + return nil, func() {}, false + } + client, release := execution.AcquireAgentCtlClient() + if client == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agentctl not ready"}) + return nil, func() {}, false + } + return client, release, true +} diff --git a/apps/backend/internal/task/handlers/workspace_preview_test.go b/apps/backend/internal/task/handlers/workspace_preview_test.go new file mode 100644 index 0000000000..67150fff79 --- /dev/null +++ b/apps/backend/internal/task/handlers/workspace_preview_test.go @@ -0,0 +1,212 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/agent/registry" + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" +) + +func TestHTTPPublishWorkspacePreviewAuthorizesReadySessionAndForwardsBuffer(t *testing.T) { + var received agentctlclient.WorkspacePreviewRequest + agentctlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/workspace/html-previews": + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Errorf("decode forwarded preview request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(agentctlclient.WorkspacePreviewResponse{ + Port: 43127, Path: "/site/index.html", Version: 3, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(agentctlServer.Close) + + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error"}) + if err != nil { + t.Fatal(err) + } + lifecycleMgr := lifecycle.NewManager( + registry.NewRegistry(log), nil, nil, nil, nil, nil, + lifecycle.ExecutorFallbackWarn, "", log, + ) + execution := &lifecycle.AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"} + parsedClient := newAgentctlClient(t, agentctlServer.URL, log) + execution.SetAgentCtlClientForTesting(parsedClient) + if err := lifecycleMgr.ExecutionStoreForTesting().Add(execution); err != nil { + t.Fatal(err) + } + + repo := &mockRepository{sessions: map[string]*models.TaskSession{ + "session-1": {ID: "session-1", TaskID: "task-1"}, + }} + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + h := &ProcessHandlers{service: svc, lifecycleMgr: lifecycleMgr, logger: log} + + body, err := json.Marshal(map[string]string{ + "repo": "frontend", + "path": "site/index.html", + "content": "unsaved", + }) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/session-1/html-previews", bytes.NewReader(body)) + c.Request = c.Request.WithContext(context.Background()) + c.Params = gin.Params{{Key: "id", Value: "session-1"}} + + h.httpPublishWorkspacePreview(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var response agentctlclient.WorkspacePreviewResponse + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Port != 43127 || response.Path != "/site/index.html" || response.Version != 3 { + t.Fatalf("response = %+v, want forwarded response", response) + } + if received.Repo != "frontend" || received.Path != "site/index.html" || received.Content != "unsaved" { + t.Fatalf("forwarded request = %+v, want current buffer", received) + } +} + +func TestHTTPPublishWorkspacePreviewPropagatesAgentctlValidationStatuses(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusRequestEntityTooLarge, http.StatusServiceUnavailable} { + t.Run(http.StatusText(status), func(t *testing.T) { + h := newWorkspacePreviewHandlers(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte("source content must not escape the handler error")) + }, true) + rec := callWorkspacePreviewHandler(t, h, strings.NewReader(`{"path":"index.html","content":"

current

"}`)) + if rec.Code != status { + t.Fatalf("status = %d, want agentctl status %d; body=%s", rec.Code, status, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "source content must not escape") { + t.Fatalf("handler exposed agentctl response body: %s", rec.Body.String()) + } + }) + } +} + +func TestHTTPPublishWorkspacePreviewMapsMalformedAgentctlResponseToBadGateway(t *testing.T) { + h := newWorkspacePreviewHandlers(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + }, true) + + rec := callWorkspacePreviewHandler(t, h, strings.NewReader(`{"path":"index.html","content":"

current

"}`)) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHTTPPublishWorkspacePreviewRejectsMalformedAndOversizedRequests(t *testing.T) { + h := newWorkspacePreviewHandlers(t, func(w http.ResponseWriter, _ *http.Request) { + t.Error("agentctl should not receive invalid requests") + w.WriteHeader(http.StatusOK) + }, true) + + malformed := callWorkspacePreviewHandler(t, h, strings.NewReader(`{"path":`)) + if malformed.Code != http.StatusBadRequest { + t.Fatalf("malformed status = %d, want 400", malformed.Code) + } + + oversized := httptest.NewRecorder() + c, _ := gin.CreateTestContext(oversized) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/session-1/html-previews", strings.NewReader(`{}`)) + c.Request.ContentLength = maxWorkspacePreviewRequestBytes + 1 + c.Params = gin.Params{{Key: "id", Value: "session-1"}} + h.httpPublishWorkspacePreview(c) + if oversized.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized status = %d, want 413", oversized.Code) + } +} + +func TestHTTPPublishWorkspacePreviewReportsUnavailableAgentctl(t *testing.T) { + h := newWorkspacePreviewHandlers(t, nil, false) + rec := callWorkspacePreviewHandler(t, h, strings.NewReader(`{"path":"index.html","content":"

current

"}`)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503; body=%s", rec.Code, rec.Body.String()) + } +} + +func callWorkspacePreviewHandler(t *testing.T, h *ProcessHandlers, body *strings.Reader) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/session-1/html-previews", body) + c.Request = c.Request.WithContext(context.Background()) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: "session-1"}} + h.httpPublishWorkspacePreview(c) + return rec +} + +func newWorkspacePreviewHandlers(t *testing.T, previewHandler http.HandlerFunc, attachClient bool) *ProcessHandlers { + t.Helper() + agentctlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(http.StatusOK) + case "/api/v1/workspace/html-previews": + if previewHandler == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + previewHandler(w, r) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(agentctlServer.Close) + + log := newTestLogger(t) + lifecycleMgr := newLifecycleManager(t, log) + execution := &lifecycle.AgentExecution{ID: "exec-1", TaskID: "task-1", SessionID: "session-1"} + if attachClient { + execution.SetAgentCtlClientForTesting(newAgentctlClient(t, agentctlServer.URL, log)) + } + if err := lifecycleMgr.ExecutionStoreForTesting().Add(execution); err != nil { + t.Fatal(err) + } + + repo := &mockRepository{sessions: map[string]*models.TaskSession{ + "session-1": {ID: "session-1", TaskID: "task-1"}, + }} + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + return &ProcessHandlers{service: svc, lifecycleMgr: lifecycleMgr, logger: log} +} diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.preview.test.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.preview.test.tsx new file mode 100644 index 0000000000..d5faa33299 --- /dev/null +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.preview.test.tsx @@ -0,0 +1,58 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@uiw/react-codemirror", () => ({ + default: () =>
, +})); + +vi.mock("./use-codemirror-editor-state", () => ({ + useCodeMirrorEditorState: () => ({ + comments: [], + diffStats: null, + extensions: [], + floatingButtonPos: null, + textSelection: null, + commentView: null, + wrapEnabled: false, + setWrapEnabled: vi.fn(), + handleChange: vi.fn(), + }), +})); + +vi.mock("./use-codemirror-walkthrough-range", () => ({ + useCodeMirrorWalkthroughRange: () => null, +})); + +vi.mock("@/components/editors/external-vcs-file-link", () => ({ + ExternalVcsFileLink: () => null, + useExternalVcsFileStatus: () => null, +})); + +import { CodeMirrorCodeEditor } from "./codemirror-code-editor"; + +afterEach(cleanup); + +describe("CodeMirrorCodeEditor preview action", () => { + it("labels and invokes HTML preview", () => { + const onPreviewHtml = vi.fn(); + render( + + + , + ); + + screen.getByRole("button", { name: "Preview HTML" }).click(); + expect(onPreviewHtml).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index c6804e5dac..60851f5d14 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -30,6 +30,7 @@ import { import { registerCodeMirrorCursorRevealer } from "@/hooks/file-editor-cursor"; import { useCodeMirrorEditorState } from "./use-codemirror-editor-state"; import { useCodeMirrorWalkthroughRange } from "./use-codemirror-walkthrough-range"; +import type { FilePreviewKind } from "@/lib/utils/file-types"; import { clearCodeMirrorCursorFlash, codeMirrorCursorFlashExtension, @@ -55,7 +56,10 @@ type FileEditorContentProps = { worktreePath?: string; repo?: string; enableComments?: boolean; - onToggleMarkdownPreview?: () => void; + previewKind?: FilePreviewKind; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; onChange: (newContent: string) => void; onSave: () => void; onReloadFromAgent?: () => void; @@ -219,22 +223,49 @@ function CodeMirrorSaveButton({ ); } -function CodeMirrorMarkdownPreviewButton({ onToggle }: { onToggle: () => void }) { +function CodeMirrorPreviewButton({ + previewKind, + onToggle, + onPreviewHtml, + isPublishingHtmlPreview, +}: { + previewKind: FilePreviewKind; + onToggle?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; +}) { const { t } = useTranslation(); + if (previewKind === "none") return null; + const isHtml = previewKind === "html"; + const action = isHtml ? onPreviewHtml : onToggle; + if (!action) return null; + const label = isHtml ? t("editors:previewHtml") : t("editors:previewMarkdown"); return ( - {t("editors:previewMarkdown")} + +

{label}

+ {isHtml && ( +

{t("task:htmlPreviewTrustedCode")}

+ )} +
); } @@ -259,7 +290,10 @@ function CodeMirrorToolbar({ onReloadFromAgent, onDelete, onDownload, - onToggleMarkdownPreview, + previewKind = "none", + onTogglePreview, + onPreviewHtml, + isPublishingHtmlPreview, }: { path: string; worktreePath?: string; @@ -279,7 +313,10 @@ function CodeMirrorToolbar({ onReloadFromAgent?: () => void; onDelete?: () => void; onDownload?: () => void; - onToggleMarkdownPreview?: () => void; + previewKind?: FilePreviewKind; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; }) { const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); return ( @@ -303,8 +340,13 @@ function CodeMirrorToolbar({ sessionId={sessionId} commentCount={commentCount} /> - {onToggleMarkdownPreview && ( - + {(onTogglePreview || onPreviewHtml) && ( + )}
void; + previewKind?: FilePreviewKind; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; onChange: (newContent: string) => void; onSave: () => void; onReloadFromAgent?: () => void; @@ -191,8 +195,51 @@ function EditorLoading() { ); } -export function MonacoCodeEditor(props: MonacoCodeEditorProps) { +function MonacoFloatingCommentButton({ state }: { state: EditorState }) { const { t } = useTranslation(); + if (!state.floatingButtonPos || state.formZoneRange) return null; + return ( + + ); +} + +type MonacoEditorViewProps = { + props: MonacoCodeEditorProps; + wrapperRef: React.RefObject; + editorAreaRef: React.RefObject; + language: string; + state: EditorState; + lsp: ReturnType; + diffStats: { additions: number; deletions: number } | null; + options: typeof EDITOR_OPTIONS & { wordWrap: "on" | "off" }; + resolvedTheme: string | undefined; + showLspStatus: boolean; + walkthroughRange: ReturnType; +}; + +function MonacoEditorView({ + props, + wrapperRef, + editorAreaRef, + language, + state, + lsp, + diffStats, + options, + resolvedTheme, + showLspStatus, + walkthroughRange, +}: MonacoEditorViewProps) { const { path, content, @@ -205,16 +252,6 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) { repo, enableComments = false, } = props; - const { resolvedTheme } = useTheme(); - const lspStatusPlacement = useLspStatusPlacement(); - const { wrapperRef, language, state, lsp, diffStats, options } = useMonacoCodeEditorSetup(props); - const editorAreaRef = useRef(null); - const walkthroughRange = useMonacoWalkthroughRange({ - editor: state.editorInstance, - editorAreaRef, - path, - repo, - }); return (
@@ -235,7 +272,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) { lspStatus={lsp.lspStatus} lspProgress={lsp.lspProgress} lspLanguage={lsp.lspLanguage} - showLspStatus={lspStatusPlacement === "toolbar"} + showLspStatus={showLspStatus} onToggleLsp={lsp.toggleLsp} onToggleWrap={() => state.setWrapEnabled(!state.wrapEnabled)} onToggleDiffIndicators={() => state.setShowDiffIndicators(!state.showDiffIndicators)} @@ -243,7 +280,10 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) { onReloadFromAgent={props.onReloadFromAgent} onDelete={props.onDelete} onDownload={props.onDownload} - onToggleMarkdownPreview={props.onToggleMarkdownPreview} + previewKind={props.previewKind} + onTogglePreview={props.onTogglePreview} + onPreviewHtml={props.onPreviewHtml} + isPublishingHtmlPreview={props.isPublishingHtmlPreview} />
) : null} - {state.floatingButtonPos && !state.formZoneRange && ( - - )} +
); } + +export function MonacoCodeEditor(props: MonacoCodeEditorProps) { + const { path, repo } = props; + const { wrapperRef, language, state, lsp, diffStats, options } = useMonacoCodeEditorSetup(props); + const { resolvedTheme } = useTheme(); + const lspStatusPlacement = useLspStatusPlacement(); + const editorAreaRef = useRef(null); + const walkthroughRange = useMonacoWalkthroughRange({ + editor: state.editorInstance, + editorAreaRef, + path, + repo, + }); + + return ( + + ); +} diff --git a/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx b/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx index a770b13aa7..5a39942ae2 100644 --- a/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx +++ b/apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx @@ -100,6 +100,44 @@ describe("MonacoEditorToolbar external file action", () => { }); }); +describe("MonacoEditorToolbar preview action", () => { + const baseProps = { + path: "reports/index.html", + isDirty: false, + isSaving: false, + diffStats: null, + wrapEnabled: false, + showDiffIndicators: false, + enableComments: false, + sessionId: "session-1", + commentCount: 0, + lspStatus: { state: "disabled" } as const, + lspProgress: { + initializingSince: null, + active: [], + completed: null, + hasReportedProgress: false, + }, + lspLanguage: null, + onToggleLsp: vi.fn(), + onToggleWrap: vi.fn(), + onToggleDiffIndicators: vi.fn(), + onSave: vi.fn(), + }; + + it("labels and invokes HTML preview", () => { + const onPreviewHtml = vi.fn(); + render( + + + , + ); + + screen.getByRole("button", { name: "Preview HTML" }).click(); + expect(onPreviewHtml).toHaveBeenCalledOnce(); + }); +}); + describe("MonacoEditorToolbar download action", () => { const baseProps = { path: "assets/report.pdf", diff --git a/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx b/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx index bbc9a1db7f..d70d810a79 100644 --- a/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx +++ b/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx @@ -24,6 +24,7 @@ import { } from "@/components/editors/external-vcs-file-link"; import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; import { LspStatusButton } from "@/components/editors/lsp-status-button"; +import type { FilePreviewKind } from "@/lib/utils/file-types"; import type { LspStatus } from "@/lib/lsp/lsp-client-manager"; import type { LspProgressSnapshot } from "@/lib/lsp/lsp-progress"; import { useTranslation } from "react-i18next"; @@ -232,22 +233,49 @@ function DownloadButton({ onDownload }: { onDownload?: () => void }) { ); } -function MarkdownPreviewButton({ onTogglePreview }: { onTogglePreview: () => void }) { +function PreviewButton({ + previewKind, + onTogglePreview, + onPreviewHtml, + isPublishingHtmlPreview, +}: { + previewKind: FilePreviewKind; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; +}) { const { t } = useTranslation(); + if (previewKind === "none") return null; + const isHtml = previewKind === "html"; + const action = isHtml ? onPreviewHtml : onTogglePreview; + if (!action) return null; + const label = isHtml ? t("editors:previewHtml") : t("editors:previewMarkdown"); return ( - {t("editors:previewMarkdown")} + +

{label}

+ {isHtml && ( +

{t("task:htmlPreviewTrustedCode")}

+ )} +
); } @@ -277,7 +305,10 @@ interface MonacoEditorToolbarProps { onReloadFromAgent?: () => void; onDelete?: () => void; onDownload?: () => void; - onToggleMarkdownPreview?: () => void; + previewKind?: FilePreviewKind; + onTogglePreview?: () => void; + onPreviewHtml?: () => void; + isPublishingHtmlPreview?: boolean; } export function MonacoEditorToolbar({ @@ -305,7 +336,10 @@ export function MonacoEditorToolbar({ onReloadFromAgent, onDelete, onDownload, - onToggleMarkdownPreview, + previewKind = "none", + onTogglePreview, + onPreviewHtml, + isPublishingHtmlPreview, }: MonacoEditorToolbarProps) { const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); return ( @@ -339,8 +373,13 @@ export function MonacoEditorToolbar({ onToggle={onToggleDiffIndicators} /> )} - {onToggleMarkdownPreview && ( - + {(onTogglePreview || onPreviewHtml) && ( + )} expect(screen.queryByRole("menuitem", { name: label })).toBeNull()); +} + function Harness(props: { makeTurnActive?: boolean; executorType?: string | null }) { const makeTurnActive = props.makeTurnActive ?? false; const executorType = Object.hasOwn(props, "executorType") ? props.executorType : "worktree"; @@ -194,7 +199,7 @@ describe("AddWorkspaceSourcesDialog repository discovery", () => { fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); expect(screen.queryByTestId("repository-discovery-controls")).toBeNull(); openRepositoryMenu(); - fireEvent.click(await screen.findByRole("menuitem", { name: "Workspace repository" })); + await selectRepositoryMenuItem("Workspace repository"); fireEvent.click(screen.getByTestId("repo-chip-trigger")); expect(screen.getByTestId("repository-discovery-controls")).toBeTruthy(); @@ -336,12 +341,12 @@ describe("AddWorkspaceSourcesDialog saved repository picker", () => { fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); await waitFor(() => expect(discoverRepositoriesAction).toHaveBeenCalledWith("workspace-1")); openRepositoryMenu(); - fireEvent.click(await screen.findByRole("menuitem", { name: "Workspace repository" })); + await selectRepositoryMenuItem("Workspace repository"); fireEvent.click(screen.getByTestId("repo-chip-trigger")); expect(await screen.findByText("discovered-project")).toBeTruthy(); - expect(screen.getByText("on disk")).toBeTruthy(); - expect(screen.getByTestId("create-local-repository-button")).toBeTruthy(); + expect(await screen.findByText("on disk")).toBeTruthy(); + expect(await screen.findByTestId("create-local-repository-button")).toBeTruthy(); fireEvent.click(screen.getByTestId("repo-refresh-button")); await waitFor(() => expect(refreshRepositories).toHaveBeenCalledOnce()); }); diff --git a/apps/web/components/task/file-editor-content.test.tsx b/apps/web/components/task/file-editor-content.test.tsx new file mode 100644 index 0000000000..8d06286adc --- /dev/null +++ b/apps/web/components/task/file-editor-content.test.tsx @@ -0,0 +1,214 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FileEditorContentProps } from "./file-editor-content"; +import { FileEditorContent } from "./file-editor-content"; + +const htmlPreview = vi.hoisted(() => ({ + status: "ready" as const, + url: "http://api.test/port-proxy/session-1/43127/reports/index.html?v=4", + error: null, + isPublishing: false, + publish: vi + .fn() + .mockResolvedValue("http://api.test/port-proxy/session-1/43127/reports/index.html?v=4"), + reset: vi.fn(), +})); + +const openBrowserPanel = vi.hoisted(() => vi.fn()); +const HTML_CONTENT = "

Report

"; +const HTML_PREVIEW_TEST_ID = "html-preview"; +const HTML_PATH = "reports/index.html"; +const MONACO_EDITOR_TEST_ID = "monaco-editor"; +const PREVIEW_HTML_LABEL = "Preview HTML"; +const SESSION_ID = "session-1"; + +vi.mock("@/hooks/use-editor-resolver", () => ({ + useEditorProvider: () => "monaco", +})); + +vi.mock("@/components/editors/monaco/monaco-code-editor", () => ({ + MonacoCodeEditor: (props: { path: string; content: string; onPreviewHtml?: () => void }) => ( +
+ {props.onPreviewHtml && ( + + )} +
+ ), +})); + +vi.mock("@/components/editors/codemirror/codemirror-code-editor", () => ({ + CodeMirrorCodeEditor: () =>
, +})); + +vi.mock("@/hooks/use-html-preview-publisher", () => ({ + useHtmlPreviewPublisher: () => htmlPreview, +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: (selector: (state: { openBrowserPanel: typeof openBrowserPanel }) => unknown) => + selector({ openBrowserPanel }), +})); + +vi.mock("./html-preview-content", () => ({ + HtmlPreviewContent: ({ + previewUrl, + onTogglePreview, + onRefresh, + onOpenInBrowser, + }: { + previewUrl?: string | null; + onTogglePreview: () => void; + onRefresh?: () => void; + onOpenInBrowser?: () => void; + }) => ( +
+ + + +
+ ), +})); + +vi.mock("./markdown-preview-content", () => ({ + MarkdownPreviewContent: (props: { content: string }) => ( +
+ ), +})); + +afterEach(cleanup); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const baseProps: FileEditorContentProps = { + path: HTML_PATH, + content: HTML_CONTENT, + originalContent: "

Old report

", + isDirty: true, + isSaving: false, + onChange: vi.fn(), + onSave: vi.fn(), +}; + +describe("FileEditorContent preview selection", () => { + it("replaces HTML source with the published iframe and restores the unchanged buffer", () => { + render(); + + expect(screen.getByTestId(MONACO_EDITOR_TEST_ID).getAttribute("data-content")).toBe( + HTML_CONTENT, + ); + fireEvent.click(screen.getByRole("button", { name: PREVIEW_HTML_LABEL })); + + expect(htmlPreview.publish).toHaveBeenCalledWith({ + path: HTML_PATH, + content: HTML_CONTENT, + }); + expect(screen.queryByTestId(MONACO_EDITOR_TEST_ID)).toBeNull(); + expect(screen.getByTestId(HTML_PREVIEW_TEST_ID).getAttribute("data-url")).toBe(htmlPreview.url); + + fireEvent.click(screen.getByRole("button", { name: "Show code" })); + expect(screen.getByTestId(MONACO_EDITOR_TEST_ID).getAttribute("data-content")).toBe( + HTML_CONTENT, + ); + expect(baseProps.onChange).not.toHaveBeenCalled(); + }); + + it("opens a Browser panel only from the explicit preview action", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: PREVIEW_HTML_LABEL })); + expect(openBrowserPanel).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Open in Browser panel" })); + expect(openBrowserPanel).toHaveBeenCalledWith(htmlPreview.url); + }); + + it("refreshes from the latest unsaved buffer", () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: PREVIEW_HTML_LABEL })); + + rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh HTML preview" })); + + expect(htmlPreview.publish).toHaveBeenLastCalledWith({ + path: HTML_PATH, + content: "

Updated report

", + }); + }); + + it("restores source and invalidates the published URL when file identity changes", () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: PREVIEW_HTML_LABEL })); + expect(screen.getByTestId(HTML_PREVIEW_TEST_ID)).toBeTruthy(); + + rerender( + , + ); + + expect(screen.getByTestId(MONACO_EDITOR_TEST_ID).getAttribute("data-path")).toBe( + "reports/next.html", + ); + expect(screen.queryByTestId(HTML_PREVIEW_TEST_ID)).toBeNull(); + expect(htmlPreview.reset).toHaveBeenCalled(); + + rerender(); + expect(screen.getByTestId(MONACO_EDITOR_TEST_ID).getAttribute("data-path")).toBe(HTML_PATH); + expect(screen.queryByTestId(HTML_PREVIEW_TEST_ID)).toBeNull(); + }); + + it("keeps Markdown preview selection separate from HTML preview", () => { + render( + , + ); + + expect(screen.getByTestId("markdown-preview").getAttribute("data-content")).toBe( + "

Report

", + ); + expect(screen.queryByTestId(HTML_PREVIEW_TEST_ID)).toBeNull(); + }); + + it("returns to the configured source editor when preview is inactive", () => { + render( + , + ); + + expect(screen.getByTestId(MONACO_EDITOR_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId(HTML_PREVIEW_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/file-editor-content.tsx b/apps/web/components/task/file-editor-content.tsx index a9c7427574..6f2d8b0bc5 100644 --- a/apps/web/components/task/file-editor-content.tsx +++ b/apps/web/components/task/file-editor-content.tsx @@ -1,9 +1,13 @@ "use client"; -import { memo } from "react"; +import { memo, useCallback, useEffect, useState } from "react"; import { useEditorProvider } from "@/hooks/use-editor-resolver"; +import { useHtmlPreviewPublisher } from "@/hooks/use-html-preview-publisher"; import { MonacoCodeEditor } from "@/components/editors/monaco/monaco-code-editor"; import { CodeMirrorCodeEditor } from "@/components/editors/codemirror/codemirror-code-editor"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import type { FilePreviewKind } from "@/lib/utils/file-types"; +import { HtmlPreviewContent } from "./html-preview-content"; import { MarkdownPreviewContent } from "./markdown-preview-content"; export type FileEditorContentProps = { @@ -20,8 +24,9 @@ export type FileEditorContentProps = { worktreePath?: string; repo?: string; enableComments?: boolean; - markdownPreview?: boolean; - onToggleMarkdownPreview?: () => void; + previewKind?: FilePreviewKind; + renderedPreview?: boolean; + onTogglePreview?: () => void; onChange: (newContent: string) => void; onSave: () => void; onReloadFromAgent?: () => void; @@ -31,8 +36,35 @@ export type FileEditorContentProps = { export const FileEditorContent = memo(function FileEditorContent(props: FileEditorContentProps) { const provider = useEditorProvider("code-editor"); + const [htmlPreviewIdentity, setHtmlPreviewIdentity] = useState(null); + const htmlPreview = useHtmlPreviewPublisher(props.sessionId ?? null); + const openBrowserPanel = useDockviewStore((state) => state.openBrowserPanel); + const fileIdentity = `${props.sessionId ?? ""}\u0000${props.repo ?? ""}\u0000${props.path}`; - if (props.markdownPreview && props.onToggleMarkdownPreview) { + useEffect(() => { + setHtmlPreviewIdentity(null); + htmlPreview.reset(); + }, [fileIdentity, htmlPreview.reset]); + + const publishCurrentHtmlPreview = useCallback(() => { + setHtmlPreviewIdentity(fileIdentity); + void htmlPreview.publish({ + path: props.path, + content: props.content, + ...(props.repo ? { repo: props.repo } : {}), + }); + }, [fileIdentity, htmlPreview.publish, props.content, props.path, props.repo]); + + const showHtmlSource = useCallback(() => { + setHtmlPreviewIdentity(null); + htmlPreview.reset(); + }, [htmlPreview.reset]); + + const openHtmlPreviewInBrowserPanel = useCallback(() => { + if (htmlPreview.url) openBrowserPanel(htmlPreview.url); + }, [htmlPreview.url, openBrowserPanel]); + + if (props.renderedPreview && props.previewKind === "markdown" && props.onTogglePreview) { return ( + ); + } + + if (htmlPreviewIdentity === fileIdentity && props.previewKind === "html") { + return ( + ); } return provider === "monaco" ? ( - + ) : ( - + ); }); diff --git a/apps/web/components/task/file-editor-panel.tsx b/apps/web/components/task/file-editor-panel.tsx index 82b49bc7b0..24998887b8 100644 --- a/apps/web/components/task/file-editor-panel.tsx +++ b/apps/web/components/task/file-editor-panel.tsx @@ -9,7 +9,7 @@ import { useAppStore } from "@/components/state-provider"; import { useDockviewStore, type FileEditorState } from "@/lib/state/dockview-store"; import { useFileEditors } from "@/hooks/use-file-editors"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; -import { getFileCategory, isMarkdownFile } from "@/lib/utils/file-types"; +import { getFileCategory, getFilePreviewKind } from "@/lib/utils/file-types"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent } from "@/lib/ws/workspace-files"; import { calculateHash } from "@/lib/utils/file-diff"; @@ -99,6 +99,16 @@ function useLoadedFileDownload( ); } +function useLoadedFileDownloadForBuffer( + file: Pick< + ReturnType, + "hasFile" | "originalHash" | "content" | "isBinary" + >, + path: string, +) { + return useLoadedFileDownload(file.hasFile, file.originalHash, path, file.content, file.isBinary); +} + type FileEditorPanelActions = Pick< ReturnType, "handleFileChange" | "saveFile" | "deleteFile" | "applyRemoteUpdate" @@ -108,14 +118,14 @@ function useFileEditorPanelActions({ path, repo, fileKey, - markdownPreview, + renderedPreview, updateFileState, actions, }: { path: string; repo: string | undefined; fileKey: string; - markdownPreview: boolean; + renderedPreview: boolean; updateFileState: (path: string, updates: Partial) => void; actions: FileEditorPanelActions; }) { @@ -130,11 +140,11 @@ function useFileEditorPanelActions({ [applyRemoteUpdate, path, repo], ); const onDelete = useCallback(() => deleteFile(path, repo), [deleteFile, path, repo]); - const onToggleMarkdownPreview = useCallback( - () => updateFileState(fileKey, { markdownPreview: !markdownPreview }), - [updateFileState, fileKey, markdownPreview], + const onTogglePreview = useCallback( + () => updateFileState(fileKey, { renderedPreview: !renderedPreview }), + [updateFileState, fileKey, renderedPreview], ); - return { onChange, onSave, onReloadFromAgent, onDelete, onToggleMarkdownPreview }; + return { onChange, onSave, onReloadFromAgent, onDelete, onTogglePreview }; } function StaticFilePanel({ @@ -327,8 +337,8 @@ function useFileEditorBuffer(fileKey: string) { const isBinary = useDockviewStore((s) => s.openFiles.get(fileKey)?.isBinary ?? false); const originalContent = useDockviewStore((s) => s.openFiles.get(fileKey)?.originalContent ?? ""); const originalHash = useDockviewStore((s) => s.openFiles.get(fileKey)?.originalHash ?? ""); - const markdownPreview = useDockviewStore( - (s) => s.openFiles.get(fileKey)?.markdownPreview ?? false, + const renderedPreview = useDockviewStore( + (s) => s.openFiles.get(fileKey)?.renderedPreview ?? false, ); return { hasFile, @@ -338,7 +348,7 @@ function useFileEditorBuffer(fileKey: string) { isBinary, originalContent, originalHash, - markdownPreview, + renderedPreview, }; } @@ -403,6 +413,87 @@ function LoadedFilePanel({ ); } +function resolveFileEditorPanelState({ + path, + repo, + isBinary, + activeSession, + activeSessionId, + activeTaskId, +}: { + path: string; + repo: string | undefined; + isBinary: boolean; + activeSession: { + workspace_path?: string | null; + worktree_path?: string | null; + repository_id?: string | null; + } | null; + activeSessionId: string | null; + activeTaskId: string | null; +}) { + return { + category: resolveFileCategory(isBinary, path), + previewKind: getFilePreviewKind(path, isBinary), + panelProps: { + worktreePath: getSessionWorkspacePath(activeSession), + sessionId: activeSessionId, + taskId: activeTaskId, + repositoryId: activeSession?.repository_id ?? undefined, + repositoryName: repo, + }, + }; +} + +type LoadedFileEditorPanelProps = { + category: FileCategory; + fileKey: string; + panelProps: Pick< + LoadedFilePanelProps, + "worktreePath" | "sessionId" | "taskId" | "repositoryId" | "repositoryName" + >; + buffer: Pick< + FileEditorContentProps, + "path" | "content" | "originalContent" | "isDirty" | "hasRemoteUpdate" | "vcsDiff" + >; + options: Pick< + FileEditorContentProps, + | "isSaving" + | "sessionId" + | "taskId" + | "repositoryId" + | "worktreePath" + | "repo" + | "enableComments" + | "previewKind" + | "renderedPreview" + | "onTogglePreview" + >; + actions: Pick< + FileEditorContentProps, + "onChange" | "onSave" | "onReloadFromAgent" | "onDelete" | "onDownload" + >; +}; + +function LoadedFileEditorPanel({ + category, + fileKey, + panelProps, + buffer, + options, + actions, +}: LoadedFileEditorPanelProps) { + return ( + + ); +} + export const FileEditorPanel = memo(function FileEditorPanel({ panelId, params, @@ -411,16 +502,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({ const repo = params.repo as string | undefined; const fileKey = buildRepoScopedItemId(path, repo); - const { - hasFile, - content, - isDirty, - hasRemoteUpdate, - isBinary, - originalContent, - originalHash, - markdownPreview, - } = useFileEditorBuffer(fileKey); + const file = useFileEditorBuffer(fileKey); const setFileState = useDockviewStore((s) => s.setFileState); const updateFileState = useDockviewStore((s) => s.updateFileState); @@ -433,10 +515,10 @@ export const FileEditorPanel = memo(function FileEditorPanel({ const vcsDiff = gitStatus?.files?.[path]?.diff; const { savingFiles, handleFileChange, saveFile, deleteFile, applyRemoteUpdate } = useFileEditors(); - useFileLoader({ hasFile, activeSessionId, fileKey, path, setFileState, repo }); + useFileLoader({ hasFile: file.hasFile, activeSessionId, fileKey, path, setFileState, repo }); useResyncOnTabActivate({ panelId, - hasFile, + hasFile: file.hasFile, activeSessionId, fileKey, path, @@ -444,12 +526,12 @@ export const FileEditorPanel = memo(function FileEditorPanel({ updateFileState, }); - const { onChange, onSave, onReloadFromAgent, onDelete, onToggleMarkdownPreview } = + const { onChange, onSave, onReloadFromAgent, onDelete, onTogglePreview } = useFileEditorPanelActions({ path, repo, fileKey, - markdownPreview, + renderedPreview: file.renderedPreview, updateFileState, actions: { handleFileChange, @@ -458,43 +540,47 @@ export const FileEditorPanel = memo(function FileEditorPanel({ applyRemoteUpdate, }, }); - const onDownload = useLoadedFileDownload(hasFile, originalHash, path, content, isBinary); + const onDownload = useLoadedFileDownloadForBuffer(file, path); - if (!hasFile || !originalHash) { + if (!file.hasFile || !file.originalHash) { return ; } - const worktreePath = getSessionWorkspacePath(activeSession); - const repositoryId = activeSession?.repository_id ?? undefined; - const category = resolveFileCategory(isBinary, path); - const isMarkdown = isMarkdownFile(path); + const { category, previewKind, panelProps } = resolveFileEditorPanelState({ + path, + repo, + isBinary: file.isBinary, + activeSession, + activeSessionId, + activeTaskId, + }); return ( - ({ FileEditorContent: ({ - markdownPreview, + previewKind, + renderedPreview, worktreePath, - onToggleMarkdownPreview, + onTogglePreview, }: { - markdownPreview?: boolean; + previewKind?: string; + renderedPreview?: boolean; worktreePath?: string; - onToggleMarkdownPreview?: () => void; + onTogglePreview?: () => void; }) => (
-
@@ -44,14 +49,14 @@ const file: OpenFileTab = { originalContent: "# README", originalHash: "hash", isDirty: false, - markdownPreview: true, + renderedPreview: true, }; afterEach(cleanup); describe("FileTabContent Markdown preview", () => { it("renders a Markdown tab in preview mode and forwards the toggle", () => { - const onToggleMarkdownPreview = vi.fn(); + const onTogglePreview = vi.fn(); render( { onFileChange={vi.fn()} onFileSave={vi.fn()} onFileDelete={vi.fn()} - onToggleMarkdownPreview={onToggleMarkdownPreview} + onTogglePreview={onTogglePreview} />, ); - expect(screen.getByTestId("file-editor-content").getAttribute("data-markdown-preview")).toBe( - "true", + expect(screen.getByTestId(FILE_EDITOR_CONTENT_TEST_ID).getAttribute("data-preview-kind")).toBe( + "markdown", ); + expect( + screen.getByTestId(FILE_EDITOR_CONTENT_TEST_ID).getAttribute("data-rendered-preview"), + ).toBe("true"); fireEvent.click(screen.getByRole("button", { name: "Toggle preview" })); - expect(onToggleMarkdownPreview).toHaveBeenCalledOnce(); + expect(onTogglePreview).toHaveBeenCalledOnce(); }); it("uses the effective workspace path for desktop file viewers", () => { @@ -91,8 +99,35 @@ describe("FileTabContent Markdown preview", () => { />, ); - expect(screen.getByTestId("file-editor-content").getAttribute("data-worktree-path")).toBe( + expect(screen.getByTestId(FILE_EDITOR_CONTENT_TEST_ID).getAttribute("data-worktree-path")).toBe( "/tmp/task-root", ); }); }); + +describe("FileTabContent HTML preview", () => { + it("delegates HTML preview ownership to the file editor content", () => { + render( + Report", + renderedPreview: false, + }} + activeSession={null} + activeSessionId="session-1" + taskId="task-1" + isSaving={false} + onFileChange={vi.fn()} + onFileSave={vi.fn()} + onFileDelete={vi.fn()} + />, + ); + + expect(screen.getByTestId(FILE_EDITOR_CONTENT_TEST_ID).getAttribute("data-preview-kind")).toBe( + "html", + ); + }); +}); diff --git a/apps/web/components/task/file-tab-content.tsx b/apps/web/components/task/file-tab-content.tsx index 90e2f349fd..05c7c0b8b2 100644 --- a/apps/web/components/task/file-tab-content.tsx +++ b/apps/web/components/task/file-tab-content.tsx @@ -5,7 +5,7 @@ import { FileEditorContent } from "./file-editor-content"; import { FileImageViewer } from "./file-image-viewer"; import { FileBinaryViewer } from "./file-binary-viewer"; import type { OpenFileTab } from "@/lib/types/backend"; -import { getFileCategory, isMarkdownFile } from "@/lib/utils/file-types"; +import { getFileCategory, getFilePreviewKind } from "@/lib/utils/file-types"; import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; import { FileViewerExternalLink } from "./file-viewer-header"; import { getFileTabKey } from "./task-center-panel-file-tabs"; @@ -24,7 +24,7 @@ export function FileTabContent({ onFileChange, onFileSave, onFileDelete, - onToggleMarkdownPreview, + onTogglePreview, }: { tab: OpenFileTab; activeSession: { @@ -38,9 +38,10 @@ export function FileTabContent({ onFileChange: (path: string, content: string, repo?: string) => void; onFileSave: (path: string, repo?: string) => void; onFileDelete: (path: string, repo?: string) => void; - onToggleMarkdownPreview?: () => void; + onTogglePreview?: () => void; }) { const category = resolveTabCategory(tab); + const previewKind = getFilePreviewKind(tab.path, !!tab.isBinary); const workspacePath = getSessionWorkspacePath(activeSession); const externalLink = ( onFileChange(tab.path, newContent, tab.repo)} onSave={() => onFileSave(tab.path, tab.repo)} onDelete={() => onFileDelete(tab.path, tab.repo)} diff --git a/apps/web/components/task/html-preview-content.test.tsx b/apps/web/components/task/html-preview-content.test.tsx new file mode 100644 index 0000000000..3f9d28913a --- /dev/null +++ b/apps/web/components/task/html-preview-content.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { HtmlPreviewContent } from "./html-preview-content"; + +vi.mock("@/components/editors/external-vcs-file-link", () => ({ + ExternalVcsFileLink: () => null, + useExternalVcsFileStatus: () => null, +})); + +afterEach(() => { + cleanup(); +}); + +function renderPreview(onTogglePreview = vi.fn(), overrides: Record = {}) { + return { + onTogglePreview, + ...render( + + + , + ), + }; +} + +describe("HtmlPreviewContent", () => { + it("renders a native iframe for the published preview URL", () => { + const { onTogglePreview } = renderPreview(); + + const frame = screen.getByTestId("html-preview-frame"); + expect(frame.getAttribute("src")).toBe( + "http://api.test/port-proxy/session-1/43127/reports/index.html?v=4", + ); + expect(frame.getAttribute("sandbox")).toContain("allow-scripts"); + expect(frame.getAttribute("sandbox")).toContain("allow-same-origin"); + expect(screen.getByTestId("html-preview-trust-warning")).toBeTruthy(); + + screen.getByRole("button", { name: "Show code" }).click(); + expect(onTogglePreview).toHaveBeenCalledOnce(); + }); + + it("shows loading state while publishing", () => { + renderPreview(vi.fn(), { previewUrl: undefined, isLoading: true }); + + expect(screen.getByTestId("html-preview-loading")).toBeTruthy(); + expect(screen.queryByTestId("html-preview-frame")).toBeNull(); + }); + + it("shows localized recovery copy and retries without leaving the preview surface", () => { + const onRetry = vi.fn(); + const { onTogglePreview } = renderPreview(vi.fn(), { + previewUrl: undefined, + error: "session-unavailable", + onRetry, + }); + + expect(screen.getByTestId("html-preview-error").textContent).toContain( + "HTML preview session is not available", + ); + screen.getByRole("button", { name: "Retry HTML preview" }).click(); + expect(onRetry).toHaveBeenCalledOnce(); + screen.getByRole("button", { name: "Show code" }).click(); + expect(onTogglePreview).toHaveBeenCalledOnce(); + }); + + it("offers refresh and an explicit Browser-panel action", () => { + const onRefresh = vi.fn(); + const onOpenInBrowser = vi.fn(); + renderPreview(vi.fn(), { onRefresh, onOpenInBrowser }); + + screen.getByRole("button", { name: "Refresh HTML preview" }).click(); + expect(onRefresh).toHaveBeenCalledOnce(); + + screen.getByRole("button", { name: /Open in browser panel/i }).click(); + expect(onOpenInBrowser).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/task/html-preview-content.tsx b/apps/web/components/task/html-preview-content.tsx new file mode 100644 index 0000000000..c382e9c1bd --- /dev/null +++ b/apps/web/components/task/html-preview-content.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { memo } from "react"; +import { IconBrowser, IconCode, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; +import { FileViewerDownloadButton } from "@/components/task/file-viewer-header"; +import { + ExternalVcsFileLink, + useExternalVcsFileStatus, +} from "@/components/editors/external-vcs-file-link"; +import { + getHtmlPreviewPublishErrorKey, + type HtmlPreviewPublishErrorCode, +} from "@/hooks/use-html-preview-publisher"; +import { toRelativePath } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +type HtmlPreviewContentProps = { + path: string; + previewUrl?: string | null; + isLoading?: boolean; + error?: HtmlPreviewPublishErrorCode | null; + worktreePath?: string; + sessionId?: string; + taskId?: string | null; + repositoryId?: string | null; + repositoryName?: string; + showExternalVcsLink?: boolean; + onDownload?: () => void; + onRefresh?: () => void; + onOpenInBrowser?: () => void; + onRetry?: () => void; + onTogglePreview: () => void; +}; + +function HtmlPreviewContentToolbar({ + path, + worktreePath, + sessionId, + taskId, + repositoryId, + repositoryName, + showExternalVcsLink, + isLoading, + onDownload, + onRefresh, + onOpenInBrowser, + onTogglePreview, +}: Omit) { + const { t } = useTranslation(); + const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName); + const trustWarning = t("task:htmlPreviewTrustedCode"); + + return ( + + {toRelativePath(path, worktreePath)} + {t("task:htmlPreview")} +
+ } + right={ +
+ {showExternalVcsLink && ( + + )} + + {onRefresh && ( + + )} + {onOpenInBrowser && ( + + )} + + + + + +

{t("task:showCode")}

+

{trustWarning}

+
+
+
+ } + /> + ); +} + +export const HtmlPreviewContent = memo(function HtmlPreviewContent({ + path, + previewUrl, + isLoading = false, + error = null, + worktreePath, + sessionId, + taskId, + repositoryId, + repositoryName, + showExternalVcsLink = true, + onDownload, + onRefresh, + onOpenInBrowser, + onRetry, + onTogglePreview, +}: HtmlPreviewContentProps) { + const { t } = useTranslation(); + const errorKey = error ? getHtmlPreviewPublishErrorKey(error) : null; + + return ( +
+ +

+ {t("task:htmlPreviewTrustedCode")} +

+
+ {isLoading && ( +
+ {t("task:htmlPreviewLoading")} +
+ )} + {!isLoading && errorKey && ( +
+

{t(errorKey)}

+ {onRetry && ( + + )} +
+ )} + {!isLoading && !errorKey && previewUrl && ( +