feat(web): add manual file upload and download to the Files panel (#3292)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -35,25 +35,10 @@ func (c *Client) MaterializeAttachment(
|
||||
if sizeBytes < 0 || sizeBytes > models.MaxMessageAttachmentBytes {
|
||||
return nil, fmt.Errorf("attachment size exceeds maximum")
|
||||
}
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
multipartWriter := multipart.NewWriter(pipeWriter)
|
||||
go func() {
|
||||
if err := writeMaterializedAttachmentMultipart(multipartWriter, sessionID, attachmentID, name, mimeType, sizeBytes, content); err != nil {
|
||||
_ = pipeWriter.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
_ = pipeWriter.Close()
|
||||
}()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/attachments/materialize", pipeReader)
|
||||
resp, err := c.doMultipartRequest(ctx, c.baseURL+"/api/v1/attachments/materialize", func(writer *multipart.Writer) error {
|
||||
return writeMaterializedAttachmentMultipart(writer, sessionID, attachmentID, name, mimeType, sizeBytes, content)
|
||||
})
|
||||
if err != nil {
|
||||
_ = pipeReader.Close()
|
||||
return nil, fmt.Errorf("create attachment materialization request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||
resp, err := c.longRunningHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
_ = pipeReader.Close()
|
||||
return nil, fmt.Errorf("materialize attachment: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// doMultipartRequest owns the pipe and writer lifecycle for streaming agentctl
|
||||
// requests. Callers only provide the endpoint and the fields/file writer.
|
||||
func (c *Client) doMultipartRequest(
|
||||
ctx context.Context,
|
||||
url string,
|
||||
write func(*multipart.Writer) error,
|
||||
) (*http.Response, error) {
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
multipartWriter := multipart.NewWriter(pipeWriter)
|
||||
go func() {
|
||||
if err := write(multipartWriter); err != nil {
|
||||
_ = pipeWriter.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
_ = pipeWriter.Close()
|
||||
}()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pipeReader)
|
||||
if err != nil {
|
||||
_ = pipeReader.Close()
|
||||
return nil, fmt.Errorf("create multipart request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||
resp, err := c.longRunningHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
_ = pipeReader.Close()
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// The upload DTOs and the conflict sentinel live in internal/agentctl/types so
|
||||
// higher-level callers can consume them without importing this runtime-tier
|
||||
// package (ARCH-RUNTIME-IMPORT).
|
||||
type (
|
||||
// UploadedWorkspaceFile is the path agentctl actually wrote.
|
||||
UploadedWorkspaceFile = agentctltypes.UploadedWorkspaceFile
|
||||
// WorkspaceUploadConflict is one destination that already exists.
|
||||
WorkspaceUploadConflict = agentctltypes.WorkspaceUploadConflict
|
||||
)
|
||||
|
||||
// ErrWorkspaceUploadConflict reports that the destination already exists and the
|
||||
// caller supplied no resolution. Callers surface this as a prompt rather than an
|
||||
// error, so it is a sentinel instead of a status string.
|
||||
var ErrWorkspaceUploadConflict = agentctltypes.ErrWorkspaceUploadConflict
|
||||
|
||||
// WorkspaceUploadParams describes one file destined for the task workspace.
|
||||
type WorkspaceUploadParams = agentctltypes.WorkspaceUploadParams
|
||||
|
||||
// PreflightWorkspaceUpload reports which of the candidate destinations already
|
||||
// exist, so the caller can resolve every conflict before sending any bytes.
|
||||
func (c *Client) PreflightWorkspaceUpload(
|
||||
ctx context.Context,
|
||||
dir, repo string,
|
||||
paths []string,
|
||||
) ([]WorkspaceUploadConflict, error) {
|
||||
payload, err := json.Marshal(map[string]any{"dir": dir, "repo": repo, "paths": paths})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode upload preflight: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
c.baseURL+"/api/v1/workspace/file/upload-preflight",
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create upload preflight request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.longRunningHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload preflight: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := checkWorkspaceUploadStatus(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Conflicts []WorkspaceUploadConflict `json:"conflicts"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode upload preflight: %w", err)
|
||||
}
|
||||
return result.Conflicts, nil
|
||||
}
|
||||
|
||||
// UploadWorkspaceFile streams one file into the task workspace. The body is
|
||||
// multipart and piped, so no layer holds the whole file in memory and nothing
|
||||
// is base64-encoded.
|
||||
func (c *Client) UploadWorkspaceFile(
|
||||
ctx context.Context,
|
||||
upload WorkspaceUploadParams,
|
||||
content io.Reader,
|
||||
) (*UploadedWorkspaceFile, error) {
|
||||
if strings.TrimSpace(upload.RelativePath) == "" || content == nil {
|
||||
return nil, fmt.Errorf("upload path and content are required")
|
||||
}
|
||||
if upload.SizeBytes < 0 || upload.SizeBytes > models.MaxMessageAttachmentBytes {
|
||||
return nil, fmt.Errorf("upload size exceeds maximum")
|
||||
}
|
||||
|
||||
resp, err := c.doMultipartRequest(ctx, c.baseURL+"/api/v1/workspace/file/upload", func(writer *multipart.Writer) error {
|
||||
return writeWorkspaceUploadMultipart(writer, upload, content)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload workspace file: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := checkWorkspaceUploadStatus(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result UploadedWorkspaceFile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode workspace upload: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(result.Path) == "" {
|
||||
return nil, fmt.Errorf("workspace upload returned no path")
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// checkWorkspaceUploadStatus converts a non-2xx response into an error,
|
||||
// preserving the conflict case as a sentinel so callers can prompt instead of
|
||||
// failing.
|
||||
func checkWorkspaceUploadStatus(resp *http.Response) error {
|
||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024))
|
||||
message := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
return fmt.Errorf("%w: %s", ErrWorkspaceUploadConflict, message)
|
||||
}
|
||||
return fmt.Errorf("workspace upload failed with status %d: %s", resp.StatusCode, message)
|
||||
}
|
||||
|
||||
func writeWorkspaceUploadMultipart(
|
||||
writer *multipart.Writer,
|
||||
upload WorkspaceUploadParams,
|
||||
content io.Reader,
|
||||
) error {
|
||||
fields := [...]struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{key: "dir", value: upload.Dir},
|
||||
{key: "repo", value: upload.Repo},
|
||||
{key: "relative_path", value: upload.RelativePath},
|
||||
{key: "resolution", value: upload.Resolution},
|
||||
{key: "size_bytes", value: strconv.FormatInt(upload.SizeBytes, 10)},
|
||||
}
|
||||
for _, field := range fields {
|
||||
if err := writer.WriteField(field.key, field.value); err != nil {
|
||||
return fmt.Errorf("write upload field %s: %w", field.key, err)
|
||||
}
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", upload.RelativePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create upload file part: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(part, content); err != nil {
|
||||
return fmt.Errorf("stream upload content: %w", err)
|
||||
}
|
||||
return writer.Close()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUploadWorkspaceFile_StreamsMultipartVerbatim(t *testing.T) {
|
||||
srv, got := captureServer(t, jsonResponder(http.StatusCreated,
|
||||
`{"path":"fixtures/sample-1.json","size_bytes":7,"resolution_applied":"keep_both"}`))
|
||||
|
||||
content := "payload"
|
||||
result, err := newHTTPOnlyClient(srv.URL).UploadWorkspaceFile(context.Background(), WorkspaceUploadParams{
|
||||
Dir: "fixtures",
|
||||
Repo: "",
|
||||
RelativePath: "sample.json",
|
||||
Resolution: "keep_both",
|
||||
SizeBytes: int64(len(content))}, strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("UploadWorkspaceFile: %v", err)
|
||||
}
|
||||
|
||||
if got.Method != http.MethodPost || got.Path != "/api/v1/workspace/file/upload" {
|
||||
t.Errorf("request = %s %s, want POST /api/v1/workspace/file/upload", got.Method, got.Path)
|
||||
}
|
||||
|
||||
fields, fileName, fileBytes := parseMaterializeUpload(t, got)
|
||||
if fields["dir"] != "fixtures" {
|
||||
t.Errorf("dir = %q, want fixtures", fields["dir"])
|
||||
}
|
||||
if fields["relative_path"] != "sample.json" {
|
||||
t.Errorf("relative_path = %q, want sample.json", fields["relative_path"])
|
||||
}
|
||||
if fields["resolution"] != "keep_both" {
|
||||
t.Errorf("resolution = %q, want keep_both", fields["resolution"])
|
||||
}
|
||||
if fields["size_bytes"] != "7" {
|
||||
t.Errorf("size_bytes = %q, want 7", fields["size_bytes"])
|
||||
}
|
||||
if fileName != "sample.json" {
|
||||
t.Errorf("file part name = %q, want sample.json", fileName)
|
||||
}
|
||||
if string(fileBytes) != content {
|
||||
t.Errorf("file bytes = %q, want %q streamed verbatim", fileBytes, content)
|
||||
}
|
||||
|
||||
// The server-chosen path is authoritative after a keep-both rename.
|
||||
if result.Path != "fixtures/sample-1.json" {
|
||||
t.Errorf("Path = %q, want the server-chosen fixtures/sample-1.json", result.Path)
|
||||
}
|
||||
if result.ResolutionApplied != "keep_both" {
|
||||
t.Errorf("ResolutionApplied = %q, want keep_both", result.ResolutionApplied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWorkspaceFile_ConflictIsASentinel(t *testing.T) {
|
||||
srv, _ := captureServer(t, jsonResponder(http.StatusConflict,
|
||||
`{"error":"upload destination already exists: taken.txt"}`))
|
||||
|
||||
_, err := newHTTPOnlyClient(srv.URL).UploadWorkspaceFile(context.Background(), WorkspaceUploadParams{
|
||||
RelativePath: "taken.txt",
|
||||
SizeBytes: 1}, strings.NewReader("x"))
|
||||
|
||||
if !errors.Is(err, ErrWorkspaceUploadConflict) {
|
||||
t.Fatalf("err = %v, want ErrWorkspaceUploadConflict", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "taken.txt") {
|
||||
t.Errorf("err = %v, want the server message preserved", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWorkspaceFile_SurfacesServerError(t *testing.T) {
|
||||
srv, _ := captureServer(t, jsonResponder(http.StatusBadRequest,
|
||||
`{"error":"path traversal detected in relative_path"}`))
|
||||
|
||||
_, err := newHTTPOnlyClient(srv.URL).UploadWorkspaceFile(context.Background(), WorkspaceUploadParams{
|
||||
RelativePath: "../escaped.txt",
|
||||
SizeBytes: 1}, strings.NewReader("x"))
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if errors.Is(err, ErrWorkspaceUploadConflict) {
|
||||
t.Error("a 400 must not be reported as a conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Errorf("err = %v, want the server message surfaced", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadWorkspaceFile_RejectsInvalidArgumentsBeforeAnyRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
upload WorkspaceUploadParams
|
||||
content io.Reader
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "blank path",
|
||||
upload: WorkspaceUploadParams{RelativePath: " ", SizeBytes: 1}, content: strings.NewReader("x"),
|
||||
wantErr: "path and content are required",
|
||||
},
|
||||
{
|
||||
name: "nil content",
|
||||
upload: WorkspaceUploadParams{RelativePath: "a.txt", SizeBytes: 1},
|
||||
wantErr: "path and content are required",
|
||||
},
|
||||
{
|
||||
name: "negative size",
|
||||
upload: WorkspaceUploadParams{RelativePath: "a.txt", SizeBytes: -1}, content: strings.NewReader("x"),
|
||||
wantErr: "size exceeds maximum",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv, got := captureServer(t, jsonResponder(http.StatusCreated, `{}`))
|
||||
_, err := newHTTPOnlyClient(srv.URL).UploadWorkspaceFile(context.Background(), tc.upload, tc.content)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want it to contain %q", err, tc.wantErr)
|
||||
}
|
||||
if got.Method != "" {
|
||||
t.Error("an invalid argument must not reach the network")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightWorkspaceUpload(t *testing.T) {
|
||||
srv, got := captureServer(t, jsonResponder(http.StatusOK,
|
||||
`{"conflicts":[{"path":"fixtures/there.txt","is_dir":false}]}`))
|
||||
|
||||
conflicts, err := newHTTPOnlyClient(srv.URL).PreflightWorkspaceUpload(
|
||||
context.Background(), "fixtures", "", []string{"there.txt", "missing.txt"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("PreflightWorkspaceUpload: %v", err)
|
||||
}
|
||||
|
||||
if got.Method != http.MethodPost || got.Path != "/api/v1/workspace/file/upload-preflight" {
|
||||
t.Errorf("request = %s %s, want POST /api/v1/workspace/file/upload-preflight", got.Method, got.Path)
|
||||
}
|
||||
body := string(got.Body)
|
||||
for _, want := range []string{`"dir":"fixtures"`, `"there.txt"`, `"missing.txt"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body %s missing %s", body, want)
|
||||
}
|
||||
}
|
||||
if len(conflicts) != 1 || conflicts[0].Path != "fixtures/there.txt" {
|
||||
t.Fatalf("conflicts = %+v, want the single reported conflict", conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightWorkspaceUpload_SurfacesServerError(t *testing.T) {
|
||||
srv, _ := captureServer(t, jsonResponder(http.StatusBadRequest, `{"error":"path outside workspace"}`))
|
||||
|
||||
_, err := newHTTPOnlyClient(srv.URL).PreflightWorkspaceUpload(
|
||||
context.Background(), "", "", []string{"../escaped.txt"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "path outside workspace") {
|
||||
t.Fatalf("err = %v, want the server message surfaced", err)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,8 @@ func (s *Server) setupRoutes() {
|
||||
api.GET("/workspace/file/content-at-ref", s.handleFileContentAtRef)
|
||||
api.POST("/workspace/file/content", s.handleFileUpdate)
|
||||
api.POST("/workspace/file/create", s.handleFileCreate)
|
||||
api.POST("/workspace/file/upload", s.handleFileUpload)
|
||||
api.POST("/workspace/file/upload-preflight", s.handleUploadPreflight)
|
||||
api.POST("/workspace/file/rename", s.handleFileRename)
|
||||
api.DELETE("/workspace/file", s.handleFileDelete)
|
||||
api.GET("/workspace/search", s.handleFileSearch)
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
taskmodels "github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// maxWorkspaceUploadBytes caps a single uploaded workspace file. It reuses the
|
||||
// message-attachment limit so an upload and a chat attachment behave alike.
|
||||
const maxWorkspaceUploadBytes int64 = taskmodels.MaxMessageAttachmentBytes
|
||||
|
||||
// workspaceUploadRequestSlack allows for multipart framing on top of the file
|
||||
// itself, matching the allowance the attachment endpoint already makes.
|
||||
const workspaceUploadRequestSlack int64 = 4 * 1024 * 1024
|
||||
|
||||
const (
|
||||
workspaceUploadPreflightBodyLimit = 1 << 20
|
||||
workspaceUploadMaxPaths = 4096
|
||||
workspaceUploadMaxPathBytes = 4096
|
||||
)
|
||||
|
||||
var errWorkspaceUploadTooLarge = errors.New("workspace upload exceeds maximum size")
|
||||
|
||||
type uploadPreflightRequest struct {
|
||||
Dir string `json:"dir"`
|
||||
Repo string `json:"repo"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
type uploadPreflightResponse struct {
|
||||
Conflicts []process.UploadConflict `json:"conflicts"`
|
||||
}
|
||||
|
||||
type workspaceUploadResponse struct {
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ResolutionApplied string `json:"resolution_applied,omitempty"`
|
||||
}
|
||||
|
||||
// handleUploadPreflight reports which of the requested destinations already
|
||||
// exist, so the caller can resolve every conflict before sending any bytes.
|
||||
func (s *Server) handleUploadPreflight(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, workspaceUploadPreflightBodyLimit)
|
||||
var req uploadPreflightRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
c.JSON(status, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Paths) > workspaceUploadMaxPaths {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "too many upload paths"})
|
||||
return
|
||||
}
|
||||
if len(req.Dir) > workspaceUploadMaxPathBytes || len(req.Repo) > workspaceUploadMaxPathBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "upload directory is too long"})
|
||||
return
|
||||
}
|
||||
for _, uploadPath := range req.Paths {
|
||||
if len(uploadPath) > workspaceUploadMaxPathBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "upload path is too long"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(req.Paths) == 0 {
|
||||
c.JSON(http.StatusOK, uploadPreflightResponse{Conflicts: []process.UploadConflict{}})
|
||||
return
|
||||
}
|
||||
|
||||
scoped := make([]string, 0, len(req.Paths))
|
||||
for _, rel := range req.Paths {
|
||||
joined, err := s.scopedUploadPath(req.Repo, req.Dir, rel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
scoped = append(scoped, joined)
|
||||
}
|
||||
|
||||
conflicts, err := s.procMgr.GetWorkspaceTracker().CheckUploadConflicts(scoped)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, uploadPreflightResponse{Conflicts: conflicts})
|
||||
}
|
||||
|
||||
// handleFileUpload streams one multipart file part into the workspace.
|
||||
func (s *Server) handleFileUpload(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxWorkspaceUploadBytes+workspaceUploadRequestSlack)
|
||||
multipartReader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "multipart upload is required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, parseErr := parseWorkspaceUploadMultipart(multipartReader)
|
||||
if parsed != nil {
|
||||
defer parsed.cleanup()
|
||||
}
|
||||
if parseErr != nil {
|
||||
c.JSON(parseErr.status, gin.H{"error": parseErr.message})
|
||||
return
|
||||
}
|
||||
if parsed.staged != nil {
|
||||
if _, err := parsed.staged.file.Seek(0, io.SeekStart); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "workspace upload failed"})
|
||||
return
|
||||
}
|
||||
parsed.source = parsed.staged.file
|
||||
}
|
||||
if parsed.source == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file part is required"})
|
||||
return
|
||||
}
|
||||
response, err := s.writeWorkspaceUploadPart(c, parsed.fields, parsed.source)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
type workspaceUploadParseError struct {
|
||||
status int
|
||||
message string
|
||||
}
|
||||
|
||||
func (e workspaceUploadParseError) Error() string { return e.message }
|
||||
|
||||
type parsedWorkspaceUpload struct {
|
||||
fields workspaceUploadFields
|
||||
source io.Reader
|
||||
sourceCloser io.Closer
|
||||
staged *stagedWorkspaceUpload
|
||||
}
|
||||
|
||||
func (p *parsedWorkspaceUpload) cleanup() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p.sourceCloser != nil {
|
||||
_ = p.sourceCloser.Close()
|
||||
}
|
||||
if p.staged != nil {
|
||||
p.staged.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
func parseWorkspaceUploadMultipart(reader *multipart.Reader) (*parsedWorkspaceUpload, *workspaceUploadParseError) {
|
||||
parsed := &parsedWorkspaceUpload{}
|
||||
for {
|
||||
part, nextErr := reader.NextPart()
|
||||
if errors.Is(nextErr, io.EOF) {
|
||||
return parsed, nil
|
||||
}
|
||||
if nextErr != nil {
|
||||
if isMultipartSizeError(nextErr) {
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusRequestEntityTooLarge, message: "upload request is too large"}
|
||||
}
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusBadRequest, message: "invalid multipart upload"}
|
||||
}
|
||||
if part.FileName() == "" {
|
||||
if parseErr := parseWorkspaceUploadField(part, &parsed.fields); parseErr != nil {
|
||||
return parsed, parseErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if part.FormName() != "file" || parsed.source != nil || parsed.staged != nil {
|
||||
_ = part.Close()
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusBadRequest, message: "exactly one file part is required"}
|
||||
}
|
||||
if !parsed.fields.allFieldsSeen() {
|
||||
staged, stageErr := stageWorkspaceUploadPart(part)
|
||||
_ = part.Close()
|
||||
if stageErr != nil {
|
||||
if errors.Is(stageErr, errWorkspaceUploadTooLarge) || isMultipartSizeError(stageErr) {
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusRequestEntityTooLarge, message: "file exceeds the maximum upload size"}
|
||||
}
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusInternalServerError, message: "workspace upload failed"}
|
||||
}
|
||||
parsed.staged = staged
|
||||
continue
|
||||
}
|
||||
parsed.source = part
|
||||
parsed.sourceCloser = part
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseWorkspaceUploadField(part *multipart.Part, fields *workspaceUploadFields) *workspaceUploadParseError {
|
||||
value, readErr := readWorkspaceUploadField(part)
|
||||
_ = part.Close()
|
||||
if readErr != nil {
|
||||
return &workspaceUploadParseError{status: http.StatusBadRequest, message: "invalid upload metadata"}
|
||||
}
|
||||
fields.set(part.FormName(), value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isMultipartSizeError(err error) bool {
|
||||
var maxErr *http.MaxBytesError
|
||||
return errors.As(err, &maxErr)
|
||||
}
|
||||
|
||||
type workspaceUploadFields struct {
|
||||
dir string
|
||||
repo string
|
||||
relativePath string
|
||||
resolution string
|
||||
declaredSize int64
|
||||
hasSize bool
|
||||
seenDir bool
|
||||
seenRepo bool
|
||||
seenPath bool
|
||||
seenResolution bool
|
||||
seenSize bool
|
||||
}
|
||||
|
||||
func (f *workspaceUploadFields) set(name, value string) {
|
||||
switch name {
|
||||
case "dir":
|
||||
f.dir = value
|
||||
f.seenDir = true
|
||||
case "repo":
|
||||
f.repo = value
|
||||
f.seenRepo = true
|
||||
case "relative_path":
|
||||
f.relativePath = value
|
||||
f.seenPath = true
|
||||
case "resolution":
|
||||
f.resolution = value
|
||||
f.seenResolution = true
|
||||
case "size_bytes":
|
||||
f.seenSize = true
|
||||
if parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil {
|
||||
f.declaredSize = parsed
|
||||
f.hasSize = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f workspaceUploadFields) allFieldsSeen() bool {
|
||||
return f.seenDir && f.seenRepo && f.seenPath && f.seenResolution && f.seenSize
|
||||
}
|
||||
|
||||
func (f workspaceUploadFields) validate() (process.UploadResolution, error) {
|
||||
if strings.TrimSpace(f.relativePath) == "" {
|
||||
return process.UploadResolutionNone, errors.New("relative_path is required")
|
||||
}
|
||||
if len(f.relativePath) > workspaceUploadMaxPathBytes || len(f.dir) > workspaceUploadMaxPathBytes || len(f.repo) > workspaceUploadMaxPathBytes {
|
||||
return process.UploadResolutionNone, errors.New("upload path is too long")
|
||||
}
|
||||
if !f.hasSize || f.declaredSize < 0 {
|
||||
return process.UploadResolutionNone, errors.New("size_bytes is required")
|
||||
}
|
||||
if f.declaredSize > maxWorkspaceUploadBytes {
|
||||
return process.UploadResolutionNone, errors.New("file exceeds the maximum upload size")
|
||||
}
|
||||
resolution, err := process.ParseUploadResolution(f.resolution)
|
||||
if err != nil {
|
||||
return process.UploadResolutionNone, err
|
||||
}
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
const workspaceUploadFieldLimit int64 = workspaceUploadMaxPathBytes + 128
|
||||
|
||||
func readWorkspaceUploadField(part *multipart.Part) (string, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(part, workspaceUploadFieldLimit+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(data)) > workspaceUploadFieldLimit {
|
||||
return "", errors.New("upload metadata is too long")
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
type boundedWorkspaceUploadReader struct {
|
||||
part io.Reader
|
||||
expected int64
|
||||
read int64
|
||||
}
|
||||
|
||||
type stagedWorkspaceUpload struct {
|
||||
file *os.File
|
||||
path string
|
||||
}
|
||||
|
||||
func stageWorkspaceUploadPart(part io.Reader) (*stagedWorkspaceUpload, error) {
|
||||
tmp, err := os.CreateTemp("", "kandev-workspace-upload-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create upload staging file: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
}
|
||||
written, err := io.Copy(tmp, io.LimitReader(part, maxWorkspaceUploadBytes+1))
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("stage upload: %w", err)
|
||||
}
|
||||
if written > maxWorkspaceUploadBytes {
|
||||
cleanup()
|
||||
return nil, errWorkspaceUploadTooLarge
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("rewind upload staging file: %w", err)
|
||||
}
|
||||
return &stagedWorkspaceUpload{file: tmp, path: tmp.Name()}, nil
|
||||
}
|
||||
|
||||
func (s *Server) writeWorkspaceUploadPart(c *gin.Context, fields workspaceUploadFields, src io.Reader) (*workspaceUploadResponse, error) {
|
||||
resolution, validateErr := fields.validate()
|
||||
if validateErr != nil {
|
||||
if fields.declaredSize > maxWorkspaceUploadBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds the maximum upload size"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": validateErr.Error()})
|
||||
}
|
||||
return nil, validateErr
|
||||
}
|
||||
scopedPath, scopeErr := s.scopedUploadPath(fields.repo, fields.dir, fields.relativePath)
|
||||
if scopeErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": scopeErr.Error()})
|
||||
return nil, scopeErr
|
||||
}
|
||||
writtenPath, written, writeErr := s.procMgr.GetWorkspaceTracker().WriteFileStream(
|
||||
scopedPath,
|
||||
resolution,
|
||||
&boundedWorkspaceUploadReader{part: src, expected: fields.declaredSize},
|
||||
)
|
||||
if writeErr != nil {
|
||||
s.respondUploadError(c, scopedPath, writeErr)
|
||||
return nil, writeErr
|
||||
}
|
||||
return &workspaceUploadResponse{
|
||||
Path: writtenPath,
|
||||
SizeBytes: written,
|
||||
ResolutionApplied: string(resolution),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stagedWorkspaceUpload) cleanup() {
|
||||
if s == nil || s.file == nil {
|
||||
return
|
||||
}
|
||||
_ = s.file.Close()
|
||||
_ = os.Remove(s.path)
|
||||
}
|
||||
|
||||
func (r *boundedWorkspaceUploadReader) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
remaining := r.expected - r.read
|
||||
if remaining < int64(len(p)) {
|
||||
p = p[:remaining+1]
|
||||
}
|
||||
n, err := r.part.Read(p)
|
||||
r.read += int64(n)
|
||||
if r.read > r.expected {
|
||||
return n, errors.New("size_bytes does not match uploaded content")
|
||||
}
|
||||
if errors.Is(err, io.EOF) && r.read != r.expected {
|
||||
return n, errors.New("size_bytes does not match uploaded content")
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// respondUploadError maps a write failure onto a status that tells the caller
|
||||
// whether to resolve a conflict, fix the request, or retry.
|
||||
func (s *Server) respondUploadError(c *gin.Context, scopedPath string, err error) {
|
||||
if errors.Is(err, process.ErrUploadConflict) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if isUploadRequestError(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.logger.Error("workspace file upload failed", zap.String("path", scopedPath), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "workspace upload failed"})
|
||||
}
|
||||
|
||||
// isUploadRequestError distinguishes a caller mistake, such as an escaping
|
||||
// path, from a genuine server-side failure.
|
||||
func isUploadRequestError(err error) bool {
|
||||
msg := err.Error()
|
||||
for _, marker := range []string{
|
||||
"path traversal",
|
||||
"path outside workspace",
|
||||
"outside the workspace",
|
||||
"invalid path",
|
||||
"is a directory",
|
||||
"size_bytes does not match",
|
||||
} {
|
||||
if strings.Contains(msg, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scopedUploadPath joins the destination directory and the file's path beneath
|
||||
// it, then applies repository scoping.
|
||||
//
|
||||
// An escaping segment is rejected rather than normalized away. Silently
|
||||
// rewriting "../x" to "x" would land the file somewhere the caller did not ask
|
||||
// for, which is a worse failure than refusing the request.
|
||||
func (s *Server) scopedUploadPath(repo, dir, relativePath string) (string, error) {
|
||||
cleanRel, err := sanitizeUploadSegment(relativePath, "relative_path")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
joined := cleanRel
|
||||
if strings.TrimSpace(dir) != "" {
|
||||
cleanDir, err := sanitizeUploadSegment(dir, "dir")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
joined = cleanDir + "/" + cleanRel
|
||||
}
|
||||
|
||||
scoped, err := s.procMgr.JoinRepoPath(repo, joined)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return scoped, nil
|
||||
}
|
||||
|
||||
// sanitizeUploadSegment validates one caller-supplied path fragment, rejecting
|
||||
// absolute paths and any traversal component.
|
||||
func sanitizeUploadSegment(raw, field string) (string, error) {
|
||||
normalized := strings.ReplaceAll(strings.TrimSpace(raw), "\\", "/")
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("invalid path: %s is empty", field)
|
||||
}
|
||||
if strings.HasPrefix(normalized, "/") {
|
||||
return "", fmt.Errorf("invalid path: %s must be relative", field)
|
||||
}
|
||||
for _, segment := range strings.Split(normalized, "/") {
|
||||
if segment == ".." {
|
||||
return "", fmt.Errorf("path traversal detected in %s", field)
|
||||
}
|
||||
}
|
||||
cleaned := strings.Trim(path.Clean(normalized), "/")
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return "", fmt.Errorf("invalid path: %s", raw)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/server/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/server/process"
|
||||
)
|
||||
|
||||
func newUploadTestServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
workDir := t.TempDir()
|
||||
log := newTestLogger()
|
||||
cfg := &config.InstanceConfig{Port: 0, WorkDir: workDir, AuthToken: "test-token"}
|
||||
return NewServer(cfg, process.NewManager(cfg, log), nil, nil, log), workDir
|
||||
}
|
||||
|
||||
type uploadForm struct {
|
||||
fields map[string]string
|
||||
name string
|
||||
content []byte
|
||||
}
|
||||
|
||||
func (f uploadForm) build(t *testing.T) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for k, v := range f.fields {
|
||||
if err := writer.WriteField(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", f.name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(f.content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
func postUpload(t *testing.T, server *Server, f uploadForm) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, contentType := f.build(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/file/upload", body)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
server.Router().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_WritesFile(t *testing.T) {
|
||||
server, workDir := newUploadTestServer(t)
|
||||
content := []byte("uploaded bytes")
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"dir": "fixtures",
|
||||
"relative_path": "sample.txt",
|
||||
"size_bytes": strconv.Itoa(len(content)),
|
||||
},
|
||||
name: "sample.txt",
|
||||
content: content,
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Path != "fixtures/sample.txt" {
|
||||
t.Errorf("path = %q, want fixtures/sample.txt", resp.Path)
|
||||
}
|
||||
if resp.SizeBytes != int64(len(content)) {
|
||||
t.Errorf("size = %d, want %d", resp.SizeBytes, len(content))
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(workDir, "fixtures", "sample.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Errorf("content = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_NestedRelativePath(t *testing.T) {
|
||||
server, workDir := newUploadTestServer(t)
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"dir": "",
|
||||
"relative_path": "tree/deep/leaf.txt",
|
||||
"size_bytes": "4",
|
||||
},
|
||||
name: "leaf.txt",
|
||||
content: []byte("leaf"),
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workDir, "tree", "deep", "leaf.txt")); err != nil {
|
||||
t.Fatalf("nested file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_ConflictWithoutResolution(t *testing.T) {
|
||||
server, workDir := newUploadTestServer(t)
|
||||
if err := os.WriteFile(filepath.Join(workDir, "taken.txt"), []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"relative_path": "taken.txt",
|
||||
"size_bytes": "8",
|
||||
},
|
||||
name: "taken.txt",
|
||||
content: []byte("incoming"),
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(workDir, "taken.txt"))
|
||||
if string(got) != "original" {
|
||||
t.Errorf("existing file changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_KeepBothReportsRenamedPath(t *testing.T) {
|
||||
server, workDir := newUploadTestServer(t)
|
||||
if err := os.WriteFile(filepath.Join(workDir, "taken.txt"), []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"relative_path": "taken.txt",
|
||||
"resolution": "keep_both",
|
||||
"size_bytes": "8",
|
||||
},
|
||||
name: "taken.txt",
|
||||
content: []byte("incoming"),
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Path string `json:"path"`
|
||||
ResolutionApplied string `json:"resolution_applied"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Path != "taken-1.txt" {
|
||||
t.Errorf("path = %q, want taken-1.txt", resp.Path)
|
||||
}
|
||||
if resp.ResolutionApplied != "keep_both" {
|
||||
t.Errorf("resolution_applied = %q, want keep_both", resp.ResolutionApplied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_ContainmentRejected(t *testing.T) {
|
||||
server, workDir := newUploadTestServer(t)
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"relative_path": "../escaped.txt",
|
||||
"size_bytes": "3",
|
||||
},
|
||||
name: "escaped.txt",
|
||||
content: []byte("bad"),
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(workDir), "escaped.txt")); err == nil {
|
||||
t.Fatal("wrote outside the workspace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_SizeMismatchRejected(t *testing.T) {
|
||||
server, workDir := newUploadTestServer(t)
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"relative_path": "mismatch.txt",
|
||||
"size_bytes": "999",
|
||||
},
|
||||
name: "mismatch.txt",
|
||||
content: []byte("short"),
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workDir, "mismatch.txt")); err == nil {
|
||||
t.Error("a file was left behind after a size mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_OversizeRejected(t *testing.T) {
|
||||
server, _ := newUploadTestServer(t)
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"relative_path": "big.bin",
|
||||
"size_bytes": strconv.FormatInt(maxWorkspaceUploadBytes+1, 10),
|
||||
},
|
||||
name: "big.bin",
|
||||
content: []byte("small body, oversized declaration"),
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want 413, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFileUpload_UnknownResolutionRejected(t *testing.T) {
|
||||
server, _ := newUploadTestServer(t)
|
||||
|
||||
rec := postUpload(t, server, uploadForm{
|
||||
fields: map[string]string{
|
||||
"relative_path": "x.txt",
|
||||
"resolution": "obliterate",
|
||||
"size_bytes": "1",
|
||||
},
|
||||
name: "x.txt",
|
||||
content: []byte("x"),
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadPreflight(t *testing.T) {
|
||||
server, workDir := newUploadTestServer(t)
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "fixtures"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "fixtures", "there.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"dir":"fixtures","paths":["there.txt","missing.txt"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/file/upload-preflight", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
server.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Conflicts []struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
} `json:"conflicts"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Conflicts) != 1 {
|
||||
t.Fatalf("conflicts = %+v, want exactly one", resp.Conflicts)
|
||||
}
|
||||
if resp.Conflicts[0].Path != "fixtures/there.txt" {
|
||||
t.Errorf("conflict path = %q, want fixtures/there.txt", resp.Conflicts[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadPreflight_ContainmentIsAnError(t *testing.T) {
|
||||
server, _ := newUploadTestServer(t)
|
||||
|
||||
body := `{"dir":"","paths":["../escaped.txt"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/file/upload-preflight", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
server.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,10 @@ type WorkspaceTracker struct {
|
||||
// updateMu prevents concurrent updateGitStatus calls from the two polling loops.
|
||||
// Polling loops use TryLock (skip if busy); RefreshGitStatus uses Lock (always completes).
|
||||
updateMu sync.Mutex
|
||||
// mutationMu serializes final upload target selection and placement. Upload
|
||||
// bytes are staged before this lock, so slow readers do not block other
|
||||
// workspace mutations while the target invariant remains atomic.
|
||||
mutationMu sync.Mutex
|
||||
|
||||
// gitStatusObserver is the expensive live repository observation. Keeping it
|
||||
// as a dependency makes the concurrency contract deterministic to test while
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
)
|
||||
|
||||
// UploadResolution says what to do when an upload's destination already exists.
|
||||
// The zero value refuses to touch an existing file, so a caller that forgets to
|
||||
// resolve a conflict cannot silently overwrite one.
|
||||
type UploadResolution string
|
||||
|
||||
const (
|
||||
// UploadResolutionNone writes only when the destination is free.
|
||||
UploadResolutionNone UploadResolution = ""
|
||||
// UploadResolutionReplace overwrites an existing destination.
|
||||
UploadResolutionReplace UploadResolution = "replace"
|
||||
// UploadResolutionKeepBoth writes beside an existing destination under the
|
||||
// next free "name-<n>.ext" variant. The hyphen form is deliberate over the
|
||||
// Windows "name (n)" convention: these land in a code workspace, where a
|
||||
// space and parentheses need quoting in every shell command and show up in
|
||||
// git paths. It also matches the attachment installer.
|
||||
UploadResolutionKeepBoth UploadResolution = "keep_both"
|
||||
)
|
||||
|
||||
// ErrUploadConflict reports that the destination exists and the caller supplied
|
||||
// no resolution. It is deliberately distinct from a validation error so the HTTP
|
||||
// layer can map it to 409 rather than 400.
|
||||
var ErrUploadConflict = errors.New("upload destination already exists")
|
||||
|
||||
// maxKeepBothAttempts bounds the "name-<n>.ext" search. It matches the bound the
|
||||
// attachment installer already uses.
|
||||
const maxKeepBothAttempts = 10000
|
||||
|
||||
// uploadTempPrefix marks in-flight upload temporaries so a crash leaves an
|
||||
// identifiable artifact rather than something that looks like workspace content.
|
||||
const uploadTempPrefix = ".kandev-upload-"
|
||||
|
||||
// UploadConflict is one destination path that already exists.
|
||||
type UploadConflict struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
// ParseUploadResolution converts the wire value into an UploadResolution,
|
||||
// rejecting anything unrecognized rather than falling back to a default.
|
||||
func ParseUploadResolution(raw string) (UploadResolution, error) {
|
||||
switch UploadResolution(strings.TrimSpace(raw)) {
|
||||
case UploadResolutionNone:
|
||||
return UploadResolutionNone, nil
|
||||
case UploadResolutionReplace:
|
||||
return UploadResolutionReplace, nil
|
||||
case UploadResolutionKeepBoth:
|
||||
return UploadResolutionKeepBoth, nil
|
||||
default:
|
||||
return UploadResolutionNone, fmt.Errorf("unknown upload resolution: %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckUploadConflicts reports which of the candidate destinations already
|
||||
// exist. Every candidate is resolved through the same containment rules as the
|
||||
// write, so a path that could never be written is an error here rather than a
|
||||
// conflict the user would be asked to resolve.
|
||||
func (wt *WorkspaceTracker) CheckUploadConflicts(reqPaths []string) ([]UploadConflict, error) {
|
||||
conflicts := make([]UploadConflict, 0, len(reqPaths))
|
||||
for _, reqPath := range reqPaths {
|
||||
path, err := wt.resolveMutationPath(reqPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, statErr := path.root.Stat(path.rel)
|
||||
_ = path.root.Close()
|
||||
if statErr != nil {
|
||||
if os.IsNotExist(statErr) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("failed to stat %s: %w", reqPath, statErr)
|
||||
}
|
||||
conflicts = append(conflicts, UploadConflict{Path: reqPath, IsDir: info.IsDir()})
|
||||
}
|
||||
return conflicts, nil
|
||||
}
|
||||
|
||||
// WriteFileStream streams src into the workspace at reqPath and returns the
|
||||
// path actually written, which differs from reqPath when the caller asked to
|
||||
// keep both copies.
|
||||
//
|
||||
// The write is staged through a temporary file in the destination directory and
|
||||
// renamed into place, so an agent reading the workspace never observes a
|
||||
// partially written file, and a failed upload leaves the destination untouched.
|
||||
func (wt *WorkspaceTracker) WriteFileStream(
|
||||
reqPath string,
|
||||
resolution UploadResolution,
|
||||
src io.Reader,
|
||||
) (string, int64, error) {
|
||||
path, err := wt.resolveMutationPath(reqPath)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer func() { _ = path.root.Close() }()
|
||||
runWorkspaceMutationBarrier()
|
||||
|
||||
dir := filepath.Dir(path.rel)
|
||||
if err := path.root.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", 0, fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
|
||||
tmpRel, tmp, err := createUploadTemp(path.root, dir)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = path.root.Remove(tmpRel)
|
||||
}
|
||||
|
||||
written, err := io.Copy(tmp, src)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", 0, fmt.Errorf("failed to write upload: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return "", 0, fmt.Errorf("failed to flush upload: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = path.root.Remove(tmpRel)
|
||||
return "", 0, fmt.Errorf("failed to close upload: %w", err)
|
||||
}
|
||||
|
||||
// Re-check and place the staged file under a real tracker lock. This lock
|
||||
// covers only metadata and rename, not the unbounded source read above.
|
||||
wt.mutationMu.Lock()
|
||||
targetRel, targetReq, err := resolveUploadTarget(path.root, path.rel, reqPath, resolution)
|
||||
if err != nil {
|
||||
wt.mutationMu.Unlock()
|
||||
_ = path.root.Remove(tmpRel)
|
||||
return "", 0, err
|
||||
}
|
||||
if err := path.root.Rename(tmpRel, targetRel); err != nil {
|
||||
wt.mutationMu.Unlock()
|
||||
_ = path.root.Remove(tmpRel)
|
||||
return "", 0, fmt.Errorf("failed to place upload: %w", err)
|
||||
}
|
||||
wt.mutationMu.Unlock()
|
||||
|
||||
notifyRel := wt.mutationNotificationPath(filepath.Join(filepath.Dir(path.safe), filepath.Base(targetRel)))
|
||||
wt.notifyFileChange(notifyRel, types.FileOpCreate)
|
||||
|
||||
return targetReq, written, nil
|
||||
}
|
||||
|
||||
// resolveUploadTarget applies the caller's resolution and returns the rooted
|
||||
// relative path to write plus the request-shaped path to report back.
|
||||
func resolveUploadTarget(
|
||||
root *os.Root,
|
||||
rel string,
|
||||
reqPath string,
|
||||
resolution UploadResolution,
|
||||
) (string, string, error) {
|
||||
info, err := root.Stat(rel)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
// Destination is free; the resolution is irrelevant.
|
||||
return rel, reqPath, nil
|
||||
case err != nil:
|
||||
return "", "", fmt.Errorf("failed to stat target: %w", err)
|
||||
}
|
||||
|
||||
switch resolution {
|
||||
case UploadResolutionReplace:
|
||||
if info.IsDir() {
|
||||
return "", "", fmt.Errorf("cannot replace destination: %s is a directory", reqPath)
|
||||
}
|
||||
return rel, reqPath, nil
|
||||
case UploadResolutionKeepBoth:
|
||||
freeRel, err := nextFreeUploadName(root, rel)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return freeRel, replaceLastSegment(reqPath, filepath.Base(freeRel)), nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("%w: %s", ErrUploadConflict, reqPath)
|
||||
}
|
||||
}
|
||||
|
||||
// nextFreeUploadName finds the first unused "name-<n>.ext" beside rel.
|
||||
func nextFreeUploadName(root *os.Root, rel string) (string, error) {
|
||||
dir := filepath.Dir(rel)
|
||||
base := filepath.Base(rel)
|
||||
ext := filepath.Ext(base)
|
||||
stem := strings.TrimSuffix(base, ext)
|
||||
|
||||
for i := 1; i <= maxKeepBothAttempts; i++ {
|
||||
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
|
||||
if _, err := root.Stat(candidate); os.IsNotExist(err) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("failed to stat candidate name: %w", err)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no available name for %s", rel)
|
||||
}
|
||||
|
||||
// replaceLastSegment swaps the final segment of a slash-separated request path.
|
||||
func replaceLastSegment(reqPath, name string) string {
|
||||
idx := strings.LastIndex(reqPath, "/")
|
||||
if idx < 0 {
|
||||
return name
|
||||
}
|
||||
return reqPath[:idx+1] + name
|
||||
}
|
||||
|
||||
// createUploadTemp opens a uniquely named temporary file inside dir through the
|
||||
// rooted handle, so the staging file is subject to the same containment as the
|
||||
// destination.
|
||||
func createUploadTemp(root *os.Root, dir string) (string, *os.File, error) {
|
||||
for attempt := 0; attempt < 1000; attempt++ {
|
||||
name := fmt.Sprintf("%s%d-%d", uploadTempPrefix, os.Getpid(), attempt)
|
||||
rel := filepath.Join(dir, name)
|
||||
f, err := root.OpenFile(rel, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
return rel, f, nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return "", nil, fmt.Errorf("failed to stage upload: %w", err)
|
||||
}
|
||||
}
|
||||
return "", nil, errors.New("failed to stage upload: no free temporary name")
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agentctl/types"
|
||||
)
|
||||
|
||||
// failingReader yields some bytes and then fails, to prove a mid-stream error
|
||||
// leaves no file and no temporary artifact behind.
|
||||
type failingReader struct {
|
||||
prefix []byte
|
||||
off int
|
||||
}
|
||||
|
||||
func (r *failingReader) Read(p []byte) (int, error) {
|
||||
if r.off < len(r.prefix) {
|
||||
n := copy(p, r.prefix[r.off:])
|
||||
r.off += n
|
||||
return n, nil
|
||||
}
|
||||
return 0, errors.New("stream broke")
|
||||
}
|
||||
|
||||
func TestWriteFileStreamContainment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
absolutePath bool
|
||||
}{
|
||||
{name: "parent traversal", path: "../escaped.txt"},
|
||||
{name: "deep traversal", path: "../../etc/passwd"},
|
||||
{name: "absolute path", absolutePath: true},
|
||||
{name: "interior traversal segment", path: "nested/../../escaped.txt"},
|
||||
{name: "traversal through existing dir", path: "sub/../../escaped.txt"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
requestPath := tc.path
|
||||
outsidePath := filepath.Join(parent, "escaped.txt")
|
||||
if tc.absolutePath {
|
||||
// Use a platform-native absolute path. Unix accepts /etc/passwd,
|
||||
// but Windows treats that spelling as a relative path.
|
||||
requestPath = filepath.Join(parent, "escaped-absolute.txt")
|
||||
outsidePath = requestPath
|
||||
}
|
||||
|
||||
_, _, err := wt.WriteFileStream(requestPath, UploadResolutionNone, strings.NewReader("payload"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected containment rejection for %q, got nil", requestPath)
|
||||
}
|
||||
if _, statErr := os.Stat(outsidePath); statErr == nil {
|
||||
t.Fatalf("wrote outside the workspace for %q", requestPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileStreamSymlinkedDirRejected(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatalf("create symlink: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := wt.WriteFileStream("link/escaped.txt", UploadResolutionNone, strings.NewReader("payload"))
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection when writing through a symlinked directory")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outside, "escaped.txt")); statErr == nil {
|
||||
t.Fatal("wrote through a symlink out of the workspace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileStreamWritesNestedFile(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
|
||||
written, n, err := wt.WriteFileStream("nested/deep/file.txt", UploadResolutionNone, strings.NewReader("hello"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileStream: %v", err)
|
||||
}
|
||||
if written != "nested/deep/file.txt" {
|
||||
t.Errorf("written path = %q, want nested/deep/file.txt", written)
|
||||
}
|
||||
if n != 5 {
|
||||
t.Errorf("bytes = %d, want 5", n)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "nested", "deep", "file.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Errorf("content = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileStreamResolutions(t *testing.T) {
|
||||
t.Run("absent resolution refuses to overwrite", func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
writeFile(t, dir, "taken.txt", "original")
|
||||
|
||||
_, _, err := wt.WriteFileStream("taken.txt", UploadResolutionNone, strings.NewReader("incoming"))
|
||||
if !errors.Is(err, ErrUploadConflict) {
|
||||
t.Fatalf("expected ErrUploadConflict, got %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "taken.txt"))
|
||||
if string(got) != "original" {
|
||||
t.Errorf("existing file was modified: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replace overwrites", func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
writeFile(t, dir, "taken.txt", "original")
|
||||
|
||||
written, _, err := wt.WriteFileStream("taken.txt", UploadResolutionReplace, strings.NewReader("incoming"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileStream: %v", err)
|
||||
}
|
||||
if written != "taken.txt" {
|
||||
t.Errorf("written path = %q, want taken.txt", written)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "taken.txt"))
|
||||
if string(got) != "incoming" {
|
||||
t.Errorf("content = %q, want incoming", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replace refuses a directory", func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
if err := os.Mkdir(filepath.Join(dir, "taken.txt"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := wt.WriteFileStream("taken.txt", UploadResolutionReplace, strings.NewReader("incoming"))
|
||||
if err == nil || !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Fatalf("expected directory rejection, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keep both renames and preserves the original", func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
writeFile(t, dir, "taken.txt", "original")
|
||||
|
||||
written, _, err := wt.WriteFileStream("taken.txt", UploadResolutionKeepBoth, strings.NewReader("incoming"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileStream: %v", err)
|
||||
}
|
||||
if written != "taken-1.txt" {
|
||||
t.Errorf("written path = %q, want taken-1.txt", written)
|
||||
}
|
||||
original, _ := os.ReadFile(filepath.Join(dir, "taken.txt"))
|
||||
if string(original) != "original" {
|
||||
t.Errorf("original was modified: %q", original)
|
||||
}
|
||||
renamed, err := os.ReadFile(filepath.Join(dir, "taken-1.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("renamed file missing: %v", err)
|
||||
}
|
||||
if string(renamed) != "incoming" {
|
||||
t.Errorf("renamed content = %q, want incoming", renamed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keep both skips names already taken", func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
writeFile(t, dir, "taken.txt", "original")
|
||||
writeFile(t, dir, "taken-1.txt", "first")
|
||||
|
||||
written, _, err := wt.WriteFileStream("taken.txt", UploadResolutionKeepBoth, strings.NewReader("incoming"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileStream: %v", err)
|
||||
}
|
||||
if written != "taken-2.txt" {
|
||||
t.Errorf("written path = %q, want taken-2.txt", written)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keep both on a free name uses it unchanged", func(t *testing.T) {
|
||||
_, wt := setupTestDir(t)
|
||||
|
||||
written, _, err := wt.WriteFileStream("fresh.txt", UploadResolutionKeepBoth, strings.NewReader("incoming"))
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFileStream: %v", err)
|
||||
}
|
||||
if written != "fresh.txt" {
|
||||
t.Errorf("written path = %q, want fresh.txt", written)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteFileStreamFailedStreamLeavesNothing(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
|
||||
_, _, err := wt.WriteFileStream("partial.bin", UploadResolutionNone, &failingReader{prefix: []byte("abc")})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from the failing reader")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "partial.bin")); statErr == nil {
|
||||
t.Error("a partial file was left at the destination")
|
||||
}
|
||||
entries, readErr := os.ReadDir(dir)
|
||||
if readErr != nil {
|
||||
t.Fatalf("readdir: %v", readErr)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), ".kandev-upload-") {
|
||||
t.Errorf("temporary artifact left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileStreamDoesNotClobberExistingOnFailure(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
writeFile(t, dir, "keep.txt", "original")
|
||||
|
||||
_, _, err := wt.WriteFileStream("keep.txt", UploadResolutionReplace, &failingReader{prefix: []byte("abc")})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from the failing reader")
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "keep.txt"))
|
||||
if string(got) != "original" {
|
||||
t.Errorf("existing file was clobbered by a failed replace: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileStreamNotifiesChange(t *testing.T) {
|
||||
_, wt := setupTestDir(t)
|
||||
sub := make(types.WorkspaceStreamSubscriber, 8)
|
||||
wt.workspaceSubMu.Lock()
|
||||
if wt.workspaceStreamSubscribers == nil {
|
||||
wt.workspaceStreamSubscribers = make(map[types.WorkspaceStreamSubscriber]struct{})
|
||||
}
|
||||
wt.workspaceStreamSubscribers[sub] = struct{}{}
|
||||
wt.workspaceSubMu.Unlock()
|
||||
|
||||
if _, _, err := wt.WriteFileStream("noted.txt", UploadResolutionNone, strings.NewReader("x")); err != nil {
|
||||
t.Fatalf("WriteFileStream: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-sub:
|
||||
if msg.FileChange == nil {
|
||||
t.Fatalf("expected a file-change notification, got %+v", msg)
|
||||
}
|
||||
if msg.FileChange.Path != "noted.txt" {
|
||||
t.Errorf("notified path = %q, want noted.txt", msg.FileChange.Path)
|
||||
}
|
||||
default:
|
||||
t.Fatal("expected a workspace change notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileStreamConcurrentKeepBothUsesUniqueNames(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
writeFile(t, dir, "taken.txt", "original")
|
||||
|
||||
const uploads = 8
|
||||
type uploadResult struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
results := make(chan uploadResult, uploads)
|
||||
for i := 0; i < uploads; i++ {
|
||||
go func(i int) {
|
||||
path, _, err := wt.WriteFileStream(
|
||||
"taken.txt",
|
||||
UploadResolutionKeepBoth,
|
||||
strings.NewReader(fmt.Sprintf("incoming-%d", i)),
|
||||
)
|
||||
results <- uploadResult{path: path, err: err}
|
||||
}(i)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < uploads; i++ {
|
||||
result := <-results
|
||||
if result.err != nil {
|
||||
t.Fatalf("concurrent upload: %v", result.err)
|
||||
}
|
||||
if seen[result.path] {
|
||||
t.Fatalf("duplicate final path %q", result.path)
|
||||
}
|
||||
seen[result.path] = true
|
||||
}
|
||||
if len(seen) != uploads {
|
||||
t.Fatalf("got %d uploaded paths, want %d: %v", len(seen), uploads, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckUploadConflicts(t *testing.T) {
|
||||
t.Run("reports only existing paths", func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
writeFile(t, dir, "there.txt", "x")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "nested"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writeFile(t, dir, filepath.Join("nested", "also.txt"), "x")
|
||||
|
||||
conflicts, err := wt.CheckUploadConflicts([]string{"there.txt", "missing.txt", "nested/also.txt"})
|
||||
if err != nil {
|
||||
t.Fatalf("CheckUploadConflicts: %v", err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, c := range conflicts {
|
||||
got[c.Path] = true
|
||||
}
|
||||
if !got["there.txt"] || !got["nested/also.txt"] {
|
||||
t.Errorf("missing expected conflicts, got %v", got)
|
||||
}
|
||||
if got["missing.txt"] {
|
||||
t.Error("reported a conflict for a path that does not exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("marks directories", func(t *testing.T) {
|
||||
dir, wt := setupTestDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "adir"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
conflicts, err := wt.CheckUploadConflicts([]string{"adir"})
|
||||
if err != nil {
|
||||
t.Fatalf("CheckUploadConflicts: %v", err)
|
||||
}
|
||||
if len(conflicts) != 1 || !conflicts[0].IsDir {
|
||||
t.Fatalf("expected one directory conflict, got %+v", conflicts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("containment failure is an error, not a conflict", func(t *testing.T) {
|
||||
_, wt := setupTestDir(t)
|
||||
|
||||
if _, err := wt.CheckUploadConflicts([]string{"../escaped.txt"}); err == nil {
|
||||
t.Fatal("expected an error for a path outside the workspace")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// io.Reader compile-time guard for the helper above.
|
||||
var _ io.Reader = (*failingReader)(nil)
|
||||
@@ -0,0 +1,35 @@
|
||||
package types
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrWorkspaceUploadConflict reports that an upload destination already exists
|
||||
// and the caller supplied no resolution.
|
||||
//
|
||||
// It lives here rather than beside the agentctl client so that higher-level
|
||||
// callers can distinguish a conflict from a failure without importing the
|
||||
// runtime-tier client package directly (ARCH-RUNTIME-IMPORT).
|
||||
var ErrWorkspaceUploadConflict = errors.New("workspace upload destination already exists")
|
||||
|
||||
// WorkspaceUploadConflict is one upload destination that already exists.
|
||||
type WorkspaceUploadConflict struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
// UploadedWorkspaceFile is the path agentctl actually wrote. It differs from the
|
||||
// requested path when the caller asked to keep both copies.
|
||||
type UploadedWorkspaceFile struct {
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ResolutionApplied string `json:"resolution_applied"`
|
||||
}
|
||||
|
||||
// WorkspaceUploadParams describes one file destined for a task workspace.
|
||||
// Content is supplied separately so this stays a plain data type.
|
||||
type WorkspaceUploadParams struct {
|
||||
Dir string
|
||||
Repo string
|
||||
RelativePath string
|
||||
Resolution string
|
||||
SizeBytes int64
|
||||
}
|
||||
@@ -1278,7 +1278,8 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han
|
||||
p.router, p.gateway.Dispatcher, p.taskSvc,
|
||||
&orchestratorWrapper{svc: p.orchestratorSvc}, p.log, referenceValidators...,
|
||||
)
|
||||
taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log)
|
||||
processHandlers := taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log)
|
||||
taskhandlers.RegisterWorkspaceFileRoutes(p.router, processHandlers)
|
||||
analyticshandlers.RegisterStatsRoutes(p.router, p.analyticsRepo, p.taskSvc, p.log)
|
||||
agenthandlers.RegisterShellRoutes(p.router, p.lifecycleMgr, p.log)
|
||||
if p.services.Share != nil {
|
||||
|
||||
@@ -37,7 +37,7 @@ func RegisterProcessRoutes(
|
||||
svc *service.Service,
|
||||
lifecycleMgr *lifecycle.Manager,
|
||||
log *logger.Logger,
|
||||
) {
|
||||
) *ProcessHandlers {
|
||||
handlers := &ProcessHandlers{
|
||||
service: svc,
|
||||
lifecycleMgr: lifecycleMgr,
|
||||
@@ -68,6 +68,7 @@ func RegisterProcessRoutes(
|
||||
session.POST("/set-model", handlers.httpSetSessionModel)
|
||||
session.POST("/set-config-option", handlers.httpSetSessionConfigOption)
|
||||
session.POST("/authenticate", handlers.httpAuthenticate)
|
||||
return handlers
|
||||
}
|
||||
|
||||
type httpStartProcessRequest struct {
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
// workspaceUploadClient is the slice of the agentctl client this handler needs.
|
||||
//
|
||||
// Declaring it here, structurally, keeps the handler off the runtime-tier
|
||||
// package (ARCH-RUNTIME-IMPORT): the concrete client satisfies it without this
|
||||
// file naming that package.
|
||||
type workspaceUploadClient interface {
|
||||
PreflightWorkspaceUpload(
|
||||
ctx context.Context, dir, repo string, paths []string,
|
||||
) ([]agentctltypes.WorkspaceUploadConflict, error)
|
||||
UploadWorkspaceFile(
|
||||
ctx context.Context, params agentctltypes.WorkspaceUploadParams, content io.Reader,
|
||||
) (*agentctltypes.UploadedWorkspaceFile, error)
|
||||
}
|
||||
|
||||
// workspaceUploadRequestSlack allows for multipart framing on top of the file.
|
||||
const workspaceUploadRequestSlack int64 = 4 * 1024 * 1024
|
||||
|
||||
// workspaceUploadPreflightBodyLimit bounds the JSON envelope before Gin
|
||||
// decodes a caller-controlled path list.
|
||||
const workspaceUploadPreflightBodyLimit int64 = 1 << 20
|
||||
|
||||
// These limits keep one preflight from consuming unbounded memory or work.
|
||||
const (
|
||||
workspaceUploadMaxPaths = 4096
|
||||
workspaceUploadMaxPathBytes = 4096
|
||||
)
|
||||
|
||||
var errWorkspaceUploadTooLarge = errors.New("workspace upload exceeds maximum size")
|
||||
|
||||
// RegisterWorkspaceFileRoutes exposes the session-scoped workspace upload
|
||||
// surface. Both routes reuse the same session authorization as the other
|
||||
// task-session routes, so an unauthenticated or unknown session is refused
|
||||
// before any byte is streamed.
|
||||
func RegisterWorkspaceFileRoutes(router *gin.Engine, handlers *ProcessHandlers) {
|
||||
files := router.Group("/api/v1/task-sessions/:id/workspace/files")
|
||||
files.POST("", handlers.httpUploadWorkspaceFile)
|
||||
files.POST("/preflight", handlers.httpPreflightWorkspaceUpload)
|
||||
}
|
||||
|
||||
type workspaceUploadPreflightRequest struct {
|
||||
Dir string `json:"dir"`
|
||||
Repo string `json:"repo"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
// httpPreflightWorkspaceUpload reports which destinations already exist so the
|
||||
// caller can resolve every conflict before uploading anything.
|
||||
func (h *ProcessHandlers) httpPreflightWorkspaceUpload(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
|
||||
}
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, workspaceUploadPreflightBodyLimit)
|
||||
var req workspaceUploadPreflightRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
c.JSON(status, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Paths) > workspaceUploadMaxPaths {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "too many upload paths"})
|
||||
return
|
||||
}
|
||||
for _, uploadPath := range req.Paths {
|
||||
if len(uploadPath) > workspaceUploadMaxPathBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "upload path is too long"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(req.Dir) > workspaceUploadMaxPathBytes || len(req.Repo) > workspaceUploadMaxPathBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "upload directory is too long"})
|
||||
return
|
||||
}
|
||||
if len(req.Paths) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"conflicts": []agentctltypes.WorkspaceUploadConflict{}})
|
||||
return
|
||||
}
|
||||
|
||||
client, release, ok := h.workspaceUploadClient(c, sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
conflicts, err := client.PreflightWorkspaceUpload(c.Request.Context(), req.Dir, req.Repo, req.Paths)
|
||||
if err != nil {
|
||||
h.respondWorkspaceUploadError(c, sessionID, err)
|
||||
return
|
||||
}
|
||||
if conflicts == nil {
|
||||
conflicts = []agentctltypes.WorkspaceUploadConflict{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"conflicts": conflicts})
|
||||
}
|
||||
|
||||
// httpUploadWorkspaceFile streams one multipart file part through to agentctl.
|
||||
// One request carries one file so that a rejection of one file in a selection
|
||||
// does not fail the rest.
|
||||
func (h *ProcessHandlers) httpUploadWorkspaceFile(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
|
||||
}
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(
|
||||
c.Writer, c.Request.Body, models.MaxMessageAttachmentBytes+workspaceUploadRequestSlack,
|
||||
)
|
||||
// MultipartReader reads from Request.Body, so the body cap is applied before
|
||||
// obtaining and consuming the reader.
|
||||
multipartReader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "multipart upload is required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, parseErr := parseTaskWorkspaceUploadMultipart(multipartReader)
|
||||
if parsed != nil {
|
||||
defer parsed.cleanup()
|
||||
}
|
||||
if parseErr != nil {
|
||||
c.JSON(parseErr.status, gin.H{"error": parseErr.message})
|
||||
return
|
||||
}
|
||||
if parsed.staged == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file part is required"})
|
||||
return
|
||||
}
|
||||
if err := parsed.fields.validate(); err != nil {
|
||||
if parsed.fields.declaredSize > models.MaxMessageAttachmentBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds the maximum upload size"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
if parsed.staged.size != parsed.fields.declaredSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "size_bytes does not match uploaded content"})
|
||||
return
|
||||
}
|
||||
if _, err := parsed.staged.file.Seek(0, io.SeekStart); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "workspace upload failed"})
|
||||
return
|
||||
}
|
||||
uploaded, err := h.forwardWorkspaceUpload(c, sessionID, parsed.fields, parsed.staged.file)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, uploaded)
|
||||
}
|
||||
|
||||
type workspaceUploadParseError struct {
|
||||
status int
|
||||
message string
|
||||
}
|
||||
|
||||
type parsedTaskWorkspaceUpload struct {
|
||||
fields workspaceUploadMultipartFields
|
||||
staged *stagedWorkspaceUpload
|
||||
}
|
||||
|
||||
func (p *parsedTaskWorkspaceUpload) cleanup() {
|
||||
if p == nil || p.staged == nil {
|
||||
return
|
||||
}
|
||||
p.staged.cleanup()
|
||||
}
|
||||
|
||||
func parseTaskWorkspaceUploadMultipart(reader *multipart.Reader) (*parsedTaskWorkspaceUpload, *workspaceUploadParseError) {
|
||||
parsed := &parsedTaskWorkspaceUpload{}
|
||||
for {
|
||||
part, nextErr := reader.NextPart()
|
||||
if errors.Is(nextErr, io.EOF) {
|
||||
return parsed, nil
|
||||
}
|
||||
if nextErr != nil {
|
||||
if isMultipartSizeError(nextErr) {
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusRequestEntityTooLarge, message: "upload request is too large"}
|
||||
}
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusBadRequest, message: "invalid multipart upload"}
|
||||
}
|
||||
if part.FileName() == "" {
|
||||
if parseErr := parseTaskWorkspaceUploadField(part, &parsed.fields); parseErr != nil {
|
||||
return parsed, parseErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if part.FormName() != "file" || parsed.staged != nil {
|
||||
_ = part.Close()
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusBadRequest, message: "exactly one file part is required"}
|
||||
}
|
||||
staged, stageErr := stageWorkspaceUploadPart(part)
|
||||
_ = part.Close()
|
||||
if stageErr != nil {
|
||||
if errors.Is(stageErr, errWorkspaceUploadTooLarge) || isMultipartSizeError(stageErr) {
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusRequestEntityTooLarge, message: "file exceeds the maximum upload size"}
|
||||
}
|
||||
return parsed, &workspaceUploadParseError{status: http.StatusInternalServerError, message: "workspace upload failed"}
|
||||
}
|
||||
parsed.staged = staged
|
||||
}
|
||||
}
|
||||
|
||||
func parseTaskWorkspaceUploadField(part *multipart.Part, fields *workspaceUploadMultipartFields) *workspaceUploadParseError {
|
||||
value, readErr := readWorkspaceUploadField(part)
|
||||
_ = part.Close()
|
||||
if readErr != nil {
|
||||
return &workspaceUploadParseError{status: http.StatusBadRequest, message: "invalid upload metadata"}
|
||||
}
|
||||
fields.set(part.FormName(), value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isMultipartSizeError(err error) bool {
|
||||
var maxErr *http.MaxBytesError
|
||||
return errors.As(err, &maxErr)
|
||||
}
|
||||
|
||||
type workspaceUploadMultipartFields struct {
|
||||
dir string
|
||||
repo string
|
||||
relativePath string
|
||||
resolution string
|
||||
declaredSize int64
|
||||
hasSize bool
|
||||
}
|
||||
|
||||
func (f *workspaceUploadMultipartFields) set(name, value string) {
|
||||
switch name {
|
||||
case "dir":
|
||||
f.dir = value
|
||||
case "repo":
|
||||
f.repo = value
|
||||
case "relative_path":
|
||||
f.relativePath = value
|
||||
case "resolution":
|
||||
f.resolution = value
|
||||
case "size_bytes":
|
||||
if parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil && parsed >= 0 {
|
||||
f.declaredSize = parsed
|
||||
f.hasSize = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f workspaceUploadMultipartFields) validate() error {
|
||||
if strings.TrimSpace(f.relativePath) == "" {
|
||||
return errors.New("relative_path is required")
|
||||
}
|
||||
if len(f.relativePath) > workspaceUploadMaxPathBytes || len(f.dir) > workspaceUploadMaxPathBytes || len(f.repo) > workspaceUploadMaxPathBytes {
|
||||
return errors.New("upload path is too long")
|
||||
}
|
||||
if !f.hasSize {
|
||||
return errors.New("size_bytes is required")
|
||||
}
|
||||
if f.declaredSize > models.MaxMessageAttachmentBytes {
|
||||
return errors.New("file exceeds the maximum upload size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const workspaceUploadFieldLimit int64 = workspaceUploadMaxPathBytes + 128
|
||||
|
||||
func readWorkspaceUploadField(part *multipart.Part) (string, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(part, workspaceUploadFieldLimit+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if int64(len(data)) > workspaceUploadFieldLimit {
|
||||
return "", errors.New("upload metadata is too long")
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// boundedWorkspaceUploadReader makes a size mismatch fail before the rooted
|
||||
// writer renames its temporary file into the destination.
|
||||
type boundedWorkspaceUploadReader struct {
|
||||
part io.Reader
|
||||
expected int64
|
||||
read int64
|
||||
}
|
||||
|
||||
type stagedWorkspaceUpload struct {
|
||||
file *os.File
|
||||
path string
|
||||
size int64
|
||||
}
|
||||
|
||||
func stageWorkspaceUploadPart(part io.Reader) (*stagedWorkspaceUpload, error) {
|
||||
tmp, err := os.CreateTemp("", "kandev-workspace-upload-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create upload staging file: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmp.Name())
|
||||
}
|
||||
written, err := io.Copy(tmp, io.LimitReader(part, models.MaxMessageAttachmentBytes+1))
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("stage upload: %w", err)
|
||||
}
|
||||
if written > models.MaxMessageAttachmentBytes {
|
||||
cleanup()
|
||||
return nil, errWorkspaceUploadTooLarge
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("rewind upload staging file: %w", err)
|
||||
}
|
||||
return &stagedWorkspaceUpload{file: tmp, path: tmp.Name(), size: written}, nil
|
||||
}
|
||||
|
||||
func (s *stagedWorkspaceUpload) cleanup() {
|
||||
if s == nil || s.file == nil {
|
||||
return
|
||||
}
|
||||
_ = s.file.Close()
|
||||
_ = os.Remove(s.path)
|
||||
}
|
||||
|
||||
func (r *boundedWorkspaceUploadReader) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
remaining := r.expected - r.read
|
||||
if remaining < int64(len(p)) {
|
||||
p = p[:remaining+1]
|
||||
}
|
||||
n, err := r.part.Read(p)
|
||||
r.read += int64(n)
|
||||
if r.read > r.expected {
|
||||
return n, fmt.Errorf("size_bytes does not match uploaded content")
|
||||
}
|
||||
if errors.Is(err, io.EOF) && r.read != r.expected {
|
||||
return n, fmt.Errorf("size_bytes does not match uploaded content")
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (h *ProcessHandlers) forwardWorkspaceUpload(c *gin.Context, sessionID string, fields workspaceUploadMultipartFields, src io.Reader) (*agentctltypes.UploadedWorkspaceFile, error) {
|
||||
if err := fields.validate(); err != nil {
|
||||
if fields.declaredSize > models.MaxMessageAttachmentBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds the maximum upload size"})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
client, release, ok := h.workspaceUploadClient(c, sessionID)
|
||||
if !ok {
|
||||
return nil, errors.New("workspace not ready")
|
||||
}
|
||||
defer release()
|
||||
uploaded, err := client.UploadWorkspaceFile(c.Request.Context(), agentctltypes.WorkspaceUploadParams{
|
||||
Dir: fields.dir,
|
||||
Repo: fields.repo,
|
||||
RelativePath: fields.relativePath,
|
||||
Resolution: fields.resolution,
|
||||
SizeBytes: fields.declaredSize,
|
||||
}, &boundedWorkspaceUploadReader{part: src, expected: fields.declaredSize})
|
||||
if err != nil {
|
||||
h.respondWorkspaceUploadError(c, sessionID, err)
|
||||
return nil, err
|
||||
}
|
||||
return uploaded, nil
|
||||
}
|
||||
|
||||
// workspaceUploadClient resolves the session's agentctl client, responding with
|
||||
// 503 when the session has no live execution to write into.
|
||||
func (h *ProcessHandlers) workspaceUploadClient(c *gin.Context, sessionID string) (workspaceUploadClient, func(), bool) {
|
||||
execution, err := h.lifecycleMgr.GetOrEnsureExecution(c.Request.Context(), sessionID)
|
||||
if err != nil || execution == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace not ready"})
|
||||
return nil, func() {}, false
|
||||
}
|
||||
client, release := execution.AcquireAgentCtlClient()
|
||||
if client == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "workspace not ready"})
|
||||
return nil, func() {}, false
|
||||
}
|
||||
return client, release, true
|
||||
}
|
||||
|
||||
// respondWorkspaceUploadError preserves the conflict case so the caller can
|
||||
// prompt for a resolution rather than treating it as a failure.
|
||||
func (h *ProcessHandlers) respondWorkspaceUploadError(c *gin.Context, sessionID string, err error) {
|
||||
if errors.Is(err, agentctltypes.ErrWorkspaceUploadConflict) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "status 413") {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds the maximum upload size"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "status 400") || strings.Contains(err.Error(), "size_bytes does not match") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Warn("workspace file upload failed",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "workspace upload failed"})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/auth/authn"
|
||||
)
|
||||
|
||||
// agentctlUploadServer answers the two agentctl upload routes and records
|
||||
// whether either was reached, so a denial can be proven to land first.
|
||||
func agentctlUploadServer(t *testing.T) (*httptest.Server, *atomic.Bool) {
|
||||
t.Helper()
|
||||
reached := &atomic.Bool{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/workspace/file/upload":
|
||||
reached.Store(true)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"path":"fixtures/sample.txt","size_bytes":7,"resolution_applied":""}`))
|
||||
case "/api/v1/workspace/file/upload-preflight":
|
||||
reached.Store(true)
|
||||
_, _ = w.Write([]byte(`{"conflicts":[{"path":"fixtures/there.txt","is_dir":false}]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server, reached
|
||||
}
|
||||
|
||||
// uploadRequestAs builds a multipart upload for sess-b as userID.
|
||||
func uploadRequestAs(t *testing.T, userID string, fields map[string]string, content string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for k, v := range fields {
|
||||
require.NoError(t, writer.WriteField(k, v))
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", "sample.txt")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/sess-b/workspace/files", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.Request = c.Request.WithContext(authn.WithIdentity(
|
||||
c.Request.Context(), authn.Identity{UserID: userID, Role: authn.RoleMember}))
|
||||
c.Params = gin.Params{{Key: "id", Value: "sess-b"}}
|
||||
return c, rec
|
||||
}
|
||||
func preflightRequestAs(t *testing.T, userID, body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/task-sessions/sess-b/workspace/files/preflight", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request = c.Request.WithContext(authn.WithIdentity(
|
||||
c.Request.Context(), authn.Identity{UserID: userID, Role: authn.RoleMember}))
|
||||
c.Params = gin.Params{{Key: "id", Value: "sess-b"}}
|
||||
return c, rec
|
||||
}
|
||||
func uploadFields(content string) map[string]string {
|
||||
return map[string]string{
|
||||
"dir": "fixtures",
|
||||
"relative_path": "sample.txt",
|
||||
"size_bytes": strconv.Itoa(len(content)),
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceFileUploadDeniesForeignSession proves the scoping guard lands
|
||||
// before any byte reaches agentctl. The owner request is the witness that the
|
||||
// denial is scoping and not an unrelated failure.
|
||||
func TestWorkspaceFileUploadDeniesForeignSession(t *testing.T) {
|
||||
server, reached := agentctlUploadServer(t)
|
||||
h := newForeignProcessHandlers(t, managerWithSessionExecution(t, server.URL, "sess-b"))
|
||||
content := "payload"
|
||||
c, rec := uploadRequestAs(t, "user-a", uploadFields(content), content)
|
||||
h.httpUploadWorkspaceFile(c)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
|
||||
require.False(t, reached.Load(), "the denial must land before agentctl is contacted")
|
||||
ownerCtx, ownerRec := uploadRequestAs(t, "user-b", uploadFields(content), content)
|
||||
h.httpUploadWorkspaceFile(ownerCtx)
|
||||
require.Equal(t, http.StatusCreated, ownerRec.Code, "body: %s", ownerRec.Body.String())
|
||||
require.Contains(t, ownerRec.Body.String(), "fixtures/sample.txt")
|
||||
require.True(t, reached.Load())
|
||||
}
|
||||
func TestWorkspaceFilePreflightDeniesForeignSession(t *testing.T) {
|
||||
server, reached := agentctlUploadServer(t)
|
||||
h := newForeignProcessHandlers(t, managerWithSessionExecution(t, server.URL, "sess-b"))
|
||||
c, rec := preflightRequestAs(t, "user-a", `{"dir":"fixtures","paths":["there.txt"]}`)
|
||||
h.httpPreflightWorkspaceUpload(c)
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
require.False(t, reached.Load(), "the denial must land before agentctl is contacted")
|
||||
ownerCtx, ownerRec := preflightRequestAs(t, "user-b", `{"dir":"fixtures","paths":["there.txt"]}`)
|
||||
h.httpPreflightWorkspaceUpload(ownerCtx)
|
||||
require.Equal(t, http.StatusOK, ownerRec.Code, "body: %s", ownerRec.Body.String())
|
||||
require.Contains(t, ownerRec.Body.String(), "fixtures/there.txt")
|
||||
require.True(t, reached.Load())
|
||||
}
|
||||
func TestWorkspaceFileUploadRequiresRelativePath(t *testing.T) {
|
||||
server, reached := agentctlUploadServer(t)
|
||||
h := newForeignProcessHandlers(t, managerWithSessionExecution(t, server.URL, "sess-b"))
|
||||
fields := uploadFields("payload")
|
||||
delete(fields, "relative_path")
|
||||
c, rec := uploadRequestAs(t, "user-b", fields, "payload")
|
||||
h.httpUploadWorkspaceFile(c)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.False(t, reached.Load(), "a malformed request must not reach agentctl")
|
||||
}
|
||||
func TestWorkspaceFileUploadRejectsSizeMismatch(t *testing.T) {
|
||||
server, reached := agentctlUploadServer(t)
|
||||
h := newForeignProcessHandlers(t, managerWithSessionExecution(t, server.URL, "sess-b"))
|
||||
fields := uploadFields("payload")
|
||||
fields["size_bytes"] = "9999"
|
||||
c, rec := uploadRequestAs(t, "user-b", fields, "payload")
|
||||
h.httpUploadWorkspaceFile(c)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.False(t, reached.Load(), "a size mismatch must not reach agentctl")
|
||||
}
|
||||
|
||||
// TestWorkspaceFileUploadWithoutExecution covers a session that is authorized
|
||||
// but has no live workspace to write into.
|
||||
func TestWorkspaceFileUploadWithoutExecution(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
h := newForeignProcessHandlers(t, newLifecycleManager(t, log))
|
||||
content := "payload"
|
||||
c, rec := uploadRequestAs(t, "user-b", uploadFields(content), content)
|
||||
h.httpUploadWorkspaceFile(c)
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code, "body: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestRegisterWorkspaceFileRoutes pins the two route paths so a rename is a
|
||||
// deliberate change rather than a silent 404 for every client.
|
||||
func TestRegisterWorkspaceFileRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
log := newTestLogger(t)
|
||||
RegisterWorkspaceFileRoutes(router, &ProcessHandlers{lifecycleMgr: newLifecycleManager(t, log), logger: log})
|
||||
paths := map[string]bool{}
|
||||
for _, route := range router.Routes() {
|
||||
paths[route.Method+" "+route.Path] = true
|
||||
}
|
||||
require.True(t, paths["POST /api/v1/task-sessions/:id/workspace/files"], "upload route missing: %v", paths)
|
||||
require.True(t, paths["POST /api/v1/task-sessions/:id/workspace/files/preflight"], "preflight route missing: %v", paths)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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: () => <div data-testid="codemirror" />,
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
const baseProps = {
|
||||
path: "assets/report.pdf",
|
||||
content: "",
|
||||
originalContent: "",
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
|
||||
describe("CodeMirrorCodeEditor download action", () => {
|
||||
it("invokes onDownload when the download control is activated", () => {
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CodeMirrorCodeEditor {...baseProps} onDownload={onDownload} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Download file" }).click();
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits the download control when no handler is supplied", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CodeMirrorCodeEditor {...baseProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Download file" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLoader2,
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
IconTextWrap,
|
||||
IconTextWrapDisabled,
|
||||
@@ -59,6 +60,7 @@ type FileEditorContentProps = {
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDownload?: () => void;
|
||||
};
|
||||
|
||||
function CodeMirrorCommentBadge({
|
||||
@@ -161,6 +163,28 @@ function CodeMirrorDeleteButton({ onDelete }: { onDelete?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CodeMirrorDownloadButton({ onDownload }: { onDownload?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (!onDownload) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDownload}
|
||||
aria-label={t("editors:downloadFile")}
|
||||
className="h-11 w-11 p-0 cursor-pointer sm:h-8 sm:w-8"
|
||||
>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editors:downloadFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeMirrorSaveButton({
|
||||
isDirty,
|
||||
isSaving,
|
||||
@@ -234,6 +258,7 @@ function CodeMirrorToolbar({
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
onDelete,
|
||||
onDownload,
|
||||
onToggleMarkdownPreview,
|
||||
}: {
|
||||
path: string;
|
||||
@@ -253,6 +278,7 @@ function CodeMirrorToolbar({
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDownload?: () => void;
|
||||
onToggleMarkdownPreview?: () => void;
|
||||
}) {
|
||||
const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName);
|
||||
@@ -295,6 +321,7 @@ function CodeMirrorToolbar({
|
||||
repositoryName={repositoryName}
|
||||
size="sm"
|
||||
/>
|
||||
<CodeMirrorDownloadButton onDownload={onDownload} />
|
||||
<CodeMirrorDeleteButton onDelete={onDelete} />
|
||||
<CodeMirrorSaveButton isDirty={isDirty} isSaving={isSaving} onSave={onSave} />
|
||||
</div>
|
||||
@@ -408,6 +435,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) {
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
onDelete,
|
||||
onDownload,
|
||||
} = props;
|
||||
const { wrapperRef, editorAreaRef, editorRef, state, walkthroughRange, handleCreateEditor } =
|
||||
useCodeMirrorCodeEditorSetup(props);
|
||||
@@ -432,6 +460,7 @@ export function CodeMirrorCodeEditor(props: FileEditorContentProps) {
|
||||
onSave={onSave}
|
||||
onReloadFromAgent={onReloadFromAgent}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
onToggleMarkdownPreview={onToggleMarkdownPreview}
|
||||
/>
|
||||
<div ref={editorAreaRef} className="flex-1 overflow-hidden relative">
|
||||
|
||||
@@ -38,6 +38,7 @@ type MonacoCodeEditorProps = {
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDownload?: () => void;
|
||||
};
|
||||
|
||||
function getMonacoTheme(resolvedTheme: string | undefined): string {
|
||||
@@ -241,6 +242,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) {
|
||||
onSave={props.onSave}
|
||||
onReloadFromAgent={props.onReloadFromAgent}
|
||||
onDelete={props.onDelete}
|
||||
onDownload={props.onDownload}
|
||||
onToggleMarkdownPreview={props.onToggleMarkdownPreview}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden relative" ref={editorAreaRef}>
|
||||
|
||||
@@ -99,3 +99,52 @@ describe("MonacoEditorToolbar external file action", () => {
|
||||
expect(screen.queryByTestId("lsp-status")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MonacoEditorToolbar download action", () => {
|
||||
const baseProps = {
|
||||
path: "assets/report.pdf",
|
||||
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("invokes onDownload when the download control is activated", async () => {
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MonacoEditorToolbar {...baseProps} onDownload={onDownload} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Download file" });
|
||||
button.click();
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits the download control when no handler is supplied", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MonacoEditorToolbar {...baseProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Download file" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLoader2,
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
IconTextWrap,
|
||||
IconTextWrapDisabled,
|
||||
@@ -209,6 +210,28 @@ function DeleteButton({ onDelete }: { onDelete?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ onDownload }: { onDownload?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (!onDownload) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDownload}
|
||||
aria-label={t("editors:downloadFile")}
|
||||
className="h-11 w-11 p-0 cursor-pointer sm:h-8 sm:w-8"
|
||||
>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editors:downloadFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownPreviewButton({ onTogglePreview }: { onTogglePreview: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -253,6 +276,7 @@ interface MonacoEditorToolbarProps {
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDownload?: () => void;
|
||||
onToggleMarkdownPreview?: () => void;
|
||||
}
|
||||
|
||||
@@ -280,6 +304,7 @@ export function MonacoEditorToolbar({
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
onDelete,
|
||||
onDownload,
|
||||
onToggleMarkdownPreview,
|
||||
}: MonacoEditorToolbarProps) {
|
||||
const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName);
|
||||
@@ -331,6 +356,7 @@ export function MonacoEditorToolbar({
|
||||
size="sm"
|
||||
/>
|
||||
<FileActionsDropdown filePath={path} sessionId={sessionId} size="sm" />
|
||||
<DownloadButton onDownload={onDownload} />
|
||||
<DeleteButton onDelete={onDelete} />
|
||||
<SaveButton isDirty={isDirty} isSaving={isSaving} onSave={onSave} />
|
||||
</div>
|
||||
|
||||
@@ -112,6 +112,20 @@ describe("applyFileChanges — refresh operation expands to all expanded folders
|
||||
expect(requestFileTreeMock.mock.calls.map((c) => c[2]).sort()).toEqual(["thm"]);
|
||||
});
|
||||
|
||||
it("refreshes the nearest loaded ancestor for a new nested path", async () => {
|
||||
mockEmptyTree();
|
||||
applyFileChanges({
|
||||
client: client(),
|
||||
sessionId: SESSION_ID,
|
||||
expandedPaths: new Set<string>(),
|
||||
changes: [{ path: "upload-bundle/nested/leaf.txt", operation: "create" }],
|
||||
setTree: vi.fn(),
|
||||
setLoadState: vi.fn(),
|
||||
});
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
expect(requestFileTreeMock.mock.calls.map((c) => c[2])).toEqual([""]);
|
||||
});
|
||||
|
||||
it("merges fresh children into the expanded subtree", async () => {
|
||||
requestFileTreeMock.mockImplementation((_c: unknown, _s: string, folder: string) =>
|
||||
folder === "thm"
|
||||
|
||||
@@ -17,6 +17,7 @@ export type FileBrowserHeaderProps = {
|
||||
onOpenFolder: () => void;
|
||||
onCollapseAll: () => void;
|
||||
showCreateButton: boolean;
|
||||
onUploadFiles?: (mode: "files" | "folder") => void;
|
||||
onAddSources?: (opener: HTMLButtonElement) => void;
|
||||
addSourcesButtonRef?: Ref<HTMLButtonElement>;
|
||||
addSourcesDisabledReason?: string;
|
||||
@@ -34,6 +35,7 @@ export function FileBrowserHeader({
|
||||
onOpenFolder,
|
||||
onCollapseAll,
|
||||
showCreateButton,
|
||||
onUploadFiles,
|
||||
onAddSources,
|
||||
addSourcesButtonRef,
|
||||
addSourcesDisabledReason,
|
||||
@@ -62,6 +64,7 @@ export function FileBrowserHeader({
|
||||
onStartSearch={() => search.setIsSearchActive(true)}
|
||||
onCollapseAll={onCollapseAll}
|
||||
showCreateButton={showCreateButton}
|
||||
onUploadFiles={onUploadFiles}
|
||||
onAddSources={onAddSources}
|
||||
addSourcesButtonRef={addSourcesButtonRef}
|
||||
addSourcesDisabledReason={addSourcesDisabledReason}
|
||||
|
||||
@@ -103,6 +103,16 @@ export function useFileBrowserSearch(sessionId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function nearestExpandedFolder(parentPath: string, expandedPaths: ReadonlySet<string>): string {
|
||||
let candidate = parentPath;
|
||||
while (candidate) {
|
||||
if (expandedPaths.has(candidate)) return candidate;
|
||||
const lastSlash = candidate.lastIndexOf("/");
|
||||
candidate = lastSlash === -1 ? "" : candidate.substring(0, lastSlash);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Apply incoming file changes to the tree by refreshing affected folders. */
|
||||
export function applyFileChanges(ctx: {
|
||||
client: ReturnType<typeof getWebSocketClient>;
|
||||
@@ -128,7 +138,7 @@ export function applyFileChanges(ctx: {
|
||||
const p = change.path;
|
||||
const lastSlash = p.lastIndexOf("/");
|
||||
const parent = lastSlash === -1 ? "" : p.substring(0, lastSlash);
|
||||
if (parent === "" || expandedPaths.has(parent)) foldersToRefresh.add(parent);
|
||||
foldersToRefresh.add(nearestExpandedFolder(parent, expandedPaths));
|
||||
if (p === "" || expandedPaths.has(p)) foldersToRefresh.add(p);
|
||||
}
|
||||
if (foldersToRefresh.size === 0) {
|
||||
|
||||
@@ -55,6 +55,7 @@ type TreeNodeRowProps = {
|
||||
onDeleteFile?: (path: string) => Promise<boolean>;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
||||
onDownloadFile?: (path: string) => Promise<boolean>;
|
||||
onUploadFilesHere?: (path: string) => void;
|
||||
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
|
||||
isSelectedFn?: (path: string) => boolean;
|
||||
onSelect?: (path: string, e: React.MouseEvent) => boolean;
|
||||
@@ -236,6 +237,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) {
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDownloadFile,
|
||||
onUploadFilesHere,
|
||||
setTree,
|
||||
showTouchActions,
|
||||
onAddToChatContext,
|
||||
@@ -313,6 +315,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) {
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onUploadFilesHere={onUploadFilesHere}
|
||||
onStartRename={rename.handleStartRename}
|
||||
onAddToChatContext={onAddToChatContext}
|
||||
selectedCount={props.selectedCount}
|
||||
@@ -436,6 +439,7 @@ type FileBrowserContentAreaProps = {
|
||||
onDeleteFile?: (path: string) => Promise<boolean>;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
||||
onDownloadFile?: (path: string) => Promise<boolean>;
|
||||
onUploadFilesHere?: (path: string) => void;
|
||||
onCreateFileSubmit: (parentPath: string, name: string) => void;
|
||||
onCancelCreate: () => void;
|
||||
onRetry: () => void;
|
||||
@@ -468,6 +472,7 @@ function rowToItemProps(props: FileBrowserContentAreaProps, row: FileBrowserRow)
|
||||
onDeleteFile: props.onDeleteFile,
|
||||
onRenameFile: props.onRenameFile,
|
||||
onDownloadFile: props.onDownloadFile,
|
||||
onUploadFilesHere: props.onUploadFilesHere,
|
||||
setTree: props.setTree,
|
||||
isSelectedFn: props.isSelectedFn,
|
||||
onSelect: props.onSelect,
|
||||
|
||||
@@ -119,3 +119,99 @@ describe("FileBrowserToolbar workspace actions", () => {
|
||||
drawer.remove();
|
||||
});
|
||||
});
|
||||
|
||||
const CREATE_MENU_TESTID = "files-create-menu";
|
||||
|
||||
describe("FileBrowserToolbar create menu", () => {
|
||||
const baseProps = {
|
||||
displayPath: "workspace",
|
||||
fullPath: "/workspace",
|
||||
copied: false,
|
||||
expandedPathsSize: 0,
|
||||
onCopyPath: vi.fn(),
|
||||
onOpenFolder: vi.fn(),
|
||||
onStartSearch: vi.fn(),
|
||||
onCollapseAll: vi.fn(),
|
||||
};
|
||||
|
||||
it("keeps one-click New File when upload is unavailable", () => {
|
||||
const onStartCreate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileBrowserToolbar {...baseProps} showCreateButton onStartCreate={onStartCreate} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New file" }));
|
||||
expect(onStartCreate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId(CREATE_MENU_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("offers New File, Upload files, and Upload folder when upload is available", async () => {
|
||||
const onStartCreate = vi.fn();
|
||||
const onUploadFiles = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileBrowserToolbar
|
||||
{...baseProps}
|
||||
showCreateButton
|
||||
onStartCreate={onStartCreate}
|
||||
onUploadFiles={onUploadFiles}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const menuTrigger = screen.getByTestId(CREATE_MENU_TESTID);
|
||||
fireEvent.pointerDown(menuTrigger, { button: 0, ctrlKey: false });
|
||||
fireEvent.click(menuTrigger);
|
||||
await waitFor(() => expect(screen.getByRole("menuitem", { name: "New file" })).toBeTruthy());
|
||||
expect(screen.getByRole("menuitem", { name: "Upload files" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Upload folder" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still begins inline creation from the menu, unchanged", async () => {
|
||||
const onStartCreate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileBrowserToolbar
|
||||
{...baseProps}
|
||||
showCreateButton
|
||||
onStartCreate={onStartCreate}
|
||||
onUploadFiles={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const menuTrigger = screen.getByTestId(CREATE_MENU_TESTID);
|
||||
fireEvent.pointerDown(menuTrigger, { button: 0, ctrlKey: false });
|
||||
fireEvent.click(menuTrigger);
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "New file" }));
|
||||
await waitFor(() => expect(onStartCreate).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("requests the right picker for each upload item", async () => {
|
||||
const onUploadFiles = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileBrowserToolbar
|
||||
{...baseProps}
|
||||
showCreateButton
|
||||
onStartCreate={vi.fn()}
|
||||
onUploadFiles={onUploadFiles}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const menuTrigger = screen.getByTestId(CREATE_MENU_TESTID);
|
||||
fireEvent.pointerDown(menuTrigger, { button: 0, ctrlKey: false });
|
||||
fireEvent.click(menuTrigger);
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Upload folder" }));
|
||||
await waitFor(() => expect(onUploadFiles).toHaveBeenCalledWith("folder"));
|
||||
|
||||
onUploadFiles.mockClear();
|
||||
fireEvent.pointerDown(menuTrigger, { button: 0, ctrlKey: false });
|
||||
fireEvent.click(menuTrigger);
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Upload files" }));
|
||||
await waitFor(() => expect(onUploadFiles).toHaveBeenCalledWith("files"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
IconFolderOpen,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconFilePlus,
|
||||
IconFolderUp,
|
||||
IconPlus,
|
||||
IconUpload,
|
||||
IconDots,
|
||||
} from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
@@ -59,6 +62,7 @@ type FileBrowserToolbarProps = {
|
||||
onStartSearch: () => void;
|
||||
onCollapseAll: () => void;
|
||||
showCreateButton: boolean;
|
||||
onUploadFiles?: (mode: "files" | "folder") => void;
|
||||
onAddSources?: (opener: HTMLButtonElement) => void;
|
||||
addSourcesButtonRef?: Ref<HTMLButtonElement>;
|
||||
addSourcesDisabledReason?: string;
|
||||
@@ -128,6 +132,71 @@ function useMobileDrawerFocusRestoration(triggerRef: RefObject<HTMLButtonElement
|
||||
}, [isMobile]);
|
||||
}
|
||||
|
||||
function CreateMenu({
|
||||
onStartCreate,
|
||||
onUploadFiles,
|
||||
}: {
|
||||
onStartCreate: () => void;
|
||||
onUploadFiles?: (mode: "files" | "folder") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Without an upload handler there is only one action, so keep the original
|
||||
// one-click button rather than burying New File behind a menu.
|
||||
if (!onUploadFiles) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
onClick={onStartCreate}
|
||||
label={t("task:newFile")}
|
||||
icon={<IconPlus className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("task:addToWorkspace")}
|
||||
data-testid="files-create-menu"
|
||||
className="inline-flex size-11 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer sm:size-8"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("task:addToWorkspace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
className="min-h-[44px] cursor-pointer gap-2 sm:min-h-8"
|
||||
onSelect={onStartCreate}
|
||||
>
|
||||
<IconFilePlus className="h-3.5 w-3.5" />
|
||||
{t("task:newFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-h-[44px] cursor-pointer gap-2 sm:min-h-8"
|
||||
onSelect={() => onUploadFiles("files")}
|
||||
>
|
||||
<IconUpload className="h-3.5 w-3.5" />
|
||||
{t("task:uploadFiles")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-h-[44px] cursor-pointer gap-2 sm:min-h-8"
|
||||
onSelect={() => onUploadFiles("folder")}
|
||||
>
|
||||
<IconFolderUp className="h-3.5 w-3.5" />
|
||||
{t("task:uploadFolder")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceActionsMenu({
|
||||
onAddSources,
|
||||
onOpenFolder,
|
||||
@@ -225,6 +294,7 @@ export function FileBrowserToolbar({
|
||||
onStartSearch,
|
||||
onCollapseAll,
|
||||
showCreateButton,
|
||||
onUploadFiles,
|
||||
onAddSources,
|
||||
addSourcesButtonRef,
|
||||
addSourcesDisabledReason,
|
||||
@@ -258,11 +328,7 @@ export function FileBrowserToolbar({
|
||||
right={
|
||||
<>
|
||||
{showCreateButton && onStartCreate && (
|
||||
<ToolbarButton
|
||||
onClick={onStartCreate}
|
||||
label={t("task:newFile")}
|
||||
icon={<IconPlus className="h-3.5 w-3.5" />}
|
||||
/>
|
||||
<CreateMenu onStartCreate={onStartCreate} onUploadFiles={onUploadFiles} />
|
||||
)}
|
||||
<WorkspaceActionsMenu
|
||||
onAddSources={onAddSources}
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
fetchAndOpenFile,
|
||||
} from "./file-browser-hooks";
|
||||
import { getFileBrowserSessionWorkspacePath, resolveFileBrowserPaths } from "./file-browser-path";
|
||||
import { useFileUploadEntryPoints } from "./use-file-upload-entry-points";
|
||||
import { FileUploadStatusList } from "./file-upload-status-list";
|
||||
import { FileTreeEditorProvider } from "./file-tree-editor-menu";
|
||||
import { computeMoveTargets, getVisiblePaths, moveNodesInTree } from "./file-tree-utils";
|
||||
import { useFileTreeReveal } from "./file-tree-reveal";
|
||||
@@ -47,6 +49,7 @@ type FileBrowserProps = {
|
||||
onDeleteFile?: (path: string) => Promise<boolean>;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
||||
onDownloadFile?: (path: string) => Promise<boolean>;
|
||||
onUploadFilesHere?: (path: string) => void;
|
||||
activeFilePath?: string | null;
|
||||
onAddSources?: (opener: HTMLButtonElement) => void;
|
||||
addSourcesButtonRef?: Ref<HTMLButtonElement>;
|
||||
@@ -462,6 +465,7 @@ function FileBrowserTreeContent({
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDownloadFile,
|
||||
onUploadFilesHere,
|
||||
showTouchActions,
|
||||
}: Omit<FileBrowserProps, "sessionId" | "environmentId" | "onOpenFile" | "onCreateFile"> & {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -494,6 +498,7 @@ function FileBrowserTreeContent({
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onUploadFilesHere={onUploadFilesHere}
|
||||
onAddToChatContext={handlers.handleAddToChatContext}
|
||||
showTouchActions={showTouchActions}
|
||||
onCreateFileSubmit={handlers.handleCreateFileSubmit}
|
||||
@@ -544,6 +549,15 @@ export function FileBrowser({
|
||||
containerRef,
|
||||
});
|
||||
const { openFolder, copied, copyPath, search, treeState, fullPath, displayPath } = data;
|
||||
const upload = useFileUploadEntryPoints(sessionId);
|
||||
const handleToolbarUpload = useCallback(
|
||||
(mode: "files" | "folder") => upload.openPicker(mode, handlers.activeFolderPath ?? ""),
|
||||
[upload, handlers.activeFolderPath],
|
||||
);
|
||||
const handleUploadHere = useCallback(
|
||||
(path: string) => upload.openPicker("files", path),
|
||||
[upload],
|
||||
);
|
||||
return (
|
||||
<FileTreeEditorProvider sessionId={sessionId} treeRootName={treeState.tree?.name}>
|
||||
<div
|
||||
@@ -564,6 +578,7 @@ export function FileBrowser({
|
||||
onOpenFolder={openFolder}
|
||||
onCollapseAll={treeState.collapseAll}
|
||||
showCreateButton={Boolean(onCreateFile)}
|
||||
onUploadFiles={sessionId ? handleToolbarUpload : undefined}
|
||||
onAddSources={onAddSources}
|
||||
addSourcesButtonRef={addSourcesButtonRef}
|
||||
addSourcesDisabledReason={addSourcesDisabledReason}
|
||||
@@ -578,8 +593,11 @@ export function FileBrowser({
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onUploadFilesHere={sessionId ? handleUploadHere : undefined}
|
||||
showTouchActions={showTouchActions}
|
||||
/>
|
||||
<FileUploadStatusList uploads={upload.uploads} />
|
||||
{upload.elements}
|
||||
</div>
|
||||
</FileTreeEditorProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { IconDownload, IconPencil, IconTrash, IconUpload } from "@tabler/icons-react";
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@kandev/ui/context-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileTreeNode } from "@/lib/types/backend";
|
||||
|
||||
export type FileContextMenuItemsProps = {
|
||||
node: FileTreeNode;
|
||||
isBulk: boolean;
|
||||
selectedCount: number;
|
||||
onDeleteFile?: (path: string) => Promise<boolean>;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
||||
onDownloadFile?: (path: string) => Promise<boolean>;
|
||||
onUploadFilesHere?: (path: string) => void;
|
||||
onStartRename: () => void;
|
||||
onDelete: (event: Event) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which node-scoped actions apply, resolved once so the render stays flat.
|
||||
*
|
||||
* Download and upload are exact inverses: a file can be downloaded, a folder can
|
||||
* receive an upload, and a multi-selection supports neither because it has no
|
||||
* single subject or destination.
|
||||
*/
|
||||
function resolveActions({
|
||||
node,
|
||||
isBulk,
|
||||
onRenameFile,
|
||||
onDownloadFile,
|
||||
onUploadFilesHere,
|
||||
}: FileContextMenuItemsProps) {
|
||||
return {
|
||||
showRename: !!onRenameFile && !isBulk,
|
||||
download: !node.is_dir && !isBulk ? onDownloadFile : undefined,
|
||||
upload: node.is_dir && !isBulk ? onUploadFilesHere : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function FileContextMenuItems(props: FileContextMenuItemsProps) {
|
||||
const { node, isBulk, selectedCount, onDeleteFile, onStartRename, onDelete } = props;
|
||||
const { t } = useTranslation();
|
||||
const { showRename, download, upload } = resolveActions(props);
|
||||
const deleteLabel = isBulk
|
||||
? t("task:deleteItemsLabel", { count: selectedCount })
|
||||
: t("task:delete");
|
||||
const hasLeadingItem = showRename || !!onDeleteFile;
|
||||
|
||||
return (
|
||||
<>
|
||||
{onDeleteFile && (
|
||||
<ContextMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
{deleteLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showRename && onDeleteFile && <ContextMenuSeparator />}
|
||||
{showRename && (
|
||||
<ContextMenuItem onSelect={onStartRename}>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
{t("task:rename")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{download && hasLeadingItem && <ContextMenuSeparator />}
|
||||
{download && (
|
||||
<ContextMenuItem onSelect={() => void download(node.path)}>
|
||||
<IconDownload className="h-3.5 w-3.5" />
|
||||
{t("task:download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{upload && hasLeadingItem && <ContextMenuSeparator />}
|
||||
{upload && (
|
||||
<ContextMenuItem onSelect={() => upload(node.path)}>
|
||||
<IconUpload className="h-3.5 w-3.5" />
|
||||
{t("task:uploadFilesHere")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -436,3 +436,50 @@ describe("FileContextMenu touch actions", () => {
|
||||
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(FILE_NODE.path));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileContextMenu upload guard", () => {
|
||||
const UPLOAD_LABEL = "Upload files here";
|
||||
const ROW = "upload-guard-row";
|
||||
|
||||
function renderFor(node: FileTreeNode, selectedCount = 1) {
|
||||
const onUploadFilesHere = vi.fn();
|
||||
render(
|
||||
<FileContextMenu
|
||||
node={node}
|
||||
tree={node}
|
||||
setTree={vi.fn()}
|
||||
onDeleteFile={vi.fn().mockResolvedValue(true)}
|
||||
onUploadFilesHere={onUploadFilesHere}
|
||||
onStartRename={vi.fn()}
|
||||
selectedCount={selectedCount}
|
||||
selectedPaths={selectedCount > 1 ? new Set(["a.txt", "b.txt"]) : undefined}
|
||||
>
|
||||
<div data-testid={ROW}>row</div>
|
||||
</FileContextMenu>,
|
||||
);
|
||||
openMenu(ROW);
|
||||
return { onUploadFilesHere };
|
||||
}
|
||||
|
||||
it("offers upload on a folder and targets that folder", async () => {
|
||||
const { onUploadFilesHere } = renderFor(DIR_NODE);
|
||||
|
||||
const item = await screen.findByRole("menuitem", { name: UPLOAD_LABEL });
|
||||
fireEvent.click(item);
|
||||
await waitFor(() => expect(onUploadFilesHere).toHaveBeenCalledWith("src"));
|
||||
});
|
||||
|
||||
it("is absent for a file, the exact inverse of the download guard", async () => {
|
||||
renderFor(FILE_NODE);
|
||||
|
||||
await screen.findByRole("menuitem", { name: "Delete" });
|
||||
expect(screen.queryByRole("menuitem", { name: UPLOAD_LABEL })).toBeNull();
|
||||
});
|
||||
|
||||
it("is absent for a multi-selection, which has no single destination", async () => {
|
||||
renderFor(DIR_NODE, 2);
|
||||
|
||||
await waitFor(() => expect(screen.queryAllByRole("menuitem").length).toBeGreaterThan(0));
|
||||
expect(screen.queryByRole("menuitem", { name: UPLOAD_LABEL })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { IconDownload, IconMessageDots, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
import { IconMessageDots } from "@tabler/icons-react";
|
||||
import { AlertDialog } from "@kandev/ui/alert-dialog";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -17,6 +17,7 @@ import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-p
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileTreeNode } from "@/lib/types/backend";
|
||||
import { FileContextMenuItems } from "./file-context-menu-items";
|
||||
import type { FileInfo } from "@/lib/state/store";
|
||||
import {
|
||||
removeNodeFromTree,
|
||||
@@ -85,59 +86,6 @@ function removeSuccessfullyDeletedPaths(
|
||||
return nextTree;
|
||||
}
|
||||
|
||||
type FileContextMenuItemsProps = {
|
||||
node: FileTreeNode;
|
||||
isBulk: boolean;
|
||||
selectedCount: number;
|
||||
onDeleteFile?: (path: string) => Promise<boolean>;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
||||
onDownloadFile?: (path: string) => Promise<boolean>;
|
||||
onStartRename: () => void;
|
||||
onDelete: (event: Event) => void;
|
||||
};
|
||||
|
||||
function FileContextMenuItems({
|
||||
node,
|
||||
isBulk,
|
||||
selectedCount,
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDownloadFile,
|
||||
onStartRename,
|
||||
onDelete,
|
||||
}: FileContextMenuItemsProps) {
|
||||
const { t } = useTranslation();
|
||||
const deleteLabel = isBulk
|
||||
? t("task:deleteItemsLabel", { count: selectedCount })
|
||||
: t("task:delete");
|
||||
const showRename = !!onRenameFile && !isBulk;
|
||||
const download = !node.is_dir && !isBulk ? onDownloadFile : undefined;
|
||||
return (
|
||||
<>
|
||||
{onDeleteFile && (
|
||||
<ContextMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
{deleteLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showRename && onDeleteFile && <ContextMenuSeparator />}
|
||||
{showRename && (
|
||||
<ContextMenuItem onSelect={onStartRename}>
|
||||
<IconPencil className="h-3.5 w-3.5" />
|
||||
{t("task:rename")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{download && (showRename || onDeleteFile) && <ContextMenuSeparator />}
|
||||
{download && (
|
||||
<ContextMenuItem onSelect={() => void download(node.path)}>
|
||||
<IconDownload className="h-3.5 w-3.5" />
|
||||
{t("task:download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatContextMenuItem({
|
||||
node,
|
||||
onAddToChatContext,
|
||||
@@ -256,6 +204,7 @@ type FileContextMenuSurfaceProps = {
|
||||
onDeleteFile?: (path: string) => Promise<boolean>;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
||||
onDownloadFile?: (path: string) => Promise<boolean>;
|
||||
onUploadFilesHere?: (path: string) => void;
|
||||
onStartRename: () => void;
|
||||
onAddToChatContext?: (node: FileTreeNode) => void;
|
||||
selectedCount: number;
|
||||
@@ -279,6 +228,7 @@ function FileContextMenuSurface({
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDownloadFile,
|
||||
onUploadFilesHere,
|
||||
onStartRename,
|
||||
onAddToChatContext,
|
||||
selectedCount,
|
||||
@@ -329,6 +279,7 @@ function FileContextMenuSurface({
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onUploadFilesHere={onUploadFilesHere}
|
||||
onStartRename={handleStartRename}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
@@ -361,6 +312,51 @@ function FileContextMenuSurface({
|
||||
}
|
||||
|
||||
/** Context menu for file nodes with Download, Rename, and Delete options */
|
||||
/**
|
||||
* Assemble the confirm-delete descriptor. Extracted so `FileContextMenu` stays
|
||||
* within the component size and complexity limits.
|
||||
*/
|
||||
function useDeleteAction({
|
||||
node,
|
||||
isBulk,
|
||||
selectedCount,
|
||||
enabled,
|
||||
confirming,
|
||||
handleDelete,
|
||||
handleConfirmDelete,
|
||||
setDeleteConfirmationOpen,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
isBulk: boolean;
|
||||
selectedCount: number;
|
||||
enabled: boolean;
|
||||
confirming: boolean;
|
||||
handleDelete: () => void;
|
||||
handleConfirmDelete: () => void;
|
||||
setDeleteConfirmationOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!enabled) return null;
|
||||
return {
|
||||
isBulk,
|
||||
selectedCount,
|
||||
confirming,
|
||||
title: isBulk
|
||||
? t("task:deleteItemsTitle", { count: selectedCount })
|
||||
: t("task:delete2", { name: node.name }),
|
||||
label: isBulk ? t("task:deleteItemsLabel", { count: selectedCount }) : t("task:delete"),
|
||||
cancelLabel: t("common:cancel"),
|
||||
description: node.is_dir ? (
|
||||
<DeleteFolderDescription name={node.name} fileCount={countFilesInTree(node)} />
|
||||
) : (
|
||||
<DeleteFileDescription name={node.name} />
|
||||
),
|
||||
onDelete: handleDelete,
|
||||
onCancel: () => setDeleteConfirmationOpen(false),
|
||||
onConfirm: handleConfirmDelete,
|
||||
};
|
||||
}
|
||||
|
||||
export function FileContextMenu({
|
||||
children,
|
||||
node,
|
||||
@@ -369,6 +365,7 @@ export function FileContextMenu({
|
||||
onDeleteFile,
|
||||
onRenameFile,
|
||||
onDownloadFile,
|
||||
onUploadFilesHere,
|
||||
onStartRename,
|
||||
onAddToChatContext,
|
||||
selectedCount = 0,
|
||||
@@ -382,6 +379,7 @@ export function FileContextMenu({
|
||||
onDeleteFile?: (path: string) => Promise<boolean>;
|
||||
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
|
||||
onDownloadFile?: (path: string) => Promise<boolean>;
|
||||
onUploadFilesHere?: (path: string) => void;
|
||||
onStartRename: () => void;
|
||||
onAddToChatContext?: (node: FileTreeNode) => void;
|
||||
selectedCount?: number;
|
||||
@@ -390,11 +388,11 @@ export function FileContextMenu({
|
||||
}) {
|
||||
const editorActions = useFileTreeEditorActions();
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
const { t } = useTranslation();
|
||||
const isBulk = selectedCount > 1;
|
||||
// A bulk selection would make a single-node "Open in <editor>" ambiguous.
|
||||
const showOpenInEditor = !isBulk && canOpenNodeInEditor(editorActions, node);
|
||||
const hasFileActions = !!onDeleteFile || !!onRenameFile || !!onDownloadFile;
|
||||
const hasFileActions =
|
||||
!!onDeleteFile || !!onRenameFile || !!onDownloadFile || !!onUploadFilesHere;
|
||||
const showAddToChatContext = !isBulk && !!onAddToChatContext;
|
||||
const fallbackAnchorRef = useRef<HTMLElement>(null);
|
||||
const anchorRef = providedAnchorRef ?? fallbackAnchorRef;
|
||||
@@ -415,26 +413,16 @@ export function FileContextMenu({
|
||||
contextMenuRef,
|
||||
});
|
||||
|
||||
const deleteAction = onDeleteFile
|
||||
? {
|
||||
isBulk,
|
||||
selectedCount,
|
||||
confirming: deleteConfirmationOpen,
|
||||
title: isBulk
|
||||
? t("task:deleteItemsTitle", { count: selectedCount })
|
||||
: t("task:delete2", { name: node.name }),
|
||||
label: isBulk ? t("task:deleteItemsLabel", { count: selectedCount }) : t("task:delete"),
|
||||
cancelLabel: t("common:cancel"),
|
||||
description: node.is_dir ? (
|
||||
<DeleteFolderDescription name={node.name} fileCount={countFilesInTree(node)} />
|
||||
) : (
|
||||
<DeleteFileDescription name={node.name} />
|
||||
),
|
||||
onDelete: handleDelete,
|
||||
onCancel: () => setDeleteConfirmationOpen(false),
|
||||
onConfirm: handleConfirmDelete,
|
||||
}
|
||||
: null;
|
||||
const deleteAction = useDeleteAction({
|
||||
node,
|
||||
isBulk,
|
||||
selectedCount,
|
||||
enabled: !!onDeleteFile,
|
||||
confirming: deleteConfirmationOpen,
|
||||
handleDelete,
|
||||
handleConfirmDelete,
|
||||
setDeleteConfirmationOpen,
|
||||
});
|
||||
|
||||
if (!hasFileActions && !showOpenInEditor && !showAddToChatContext) return <>{children}</>;
|
||||
|
||||
@@ -444,6 +432,7 @@ export function FileContextMenu({
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onUploadFilesHere={onUploadFilesHere}
|
||||
onStartRename={onStartRename}
|
||||
onAddToChatContext={onAddToChatContext}
|
||||
selectedCount={selectedCount}
|
||||
|
||||
@@ -26,6 +26,7 @@ export type FileEditorContentProps = {
|
||||
onSave: () => void;
|
||||
onReloadFromAgent?: () => void;
|
||||
onDelete?: () => void;
|
||||
onDownload?: () => void;
|
||||
};
|
||||
|
||||
export const FileEditorContent = memo(function FileEditorContent(props: FileEditorContentProps) {
|
||||
@@ -42,6 +43,7 @@ export const FileEditorContent = memo(function FileEditorContent(props: FileEdit
|
||||
repositoryId={props.repositoryId}
|
||||
repositoryName={props.repo}
|
||||
enableComments={props.enableComments}
|
||||
onDownload={props.onDownload}
|
||||
onTogglePreview={props.onToggleMarkdownPreview}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const triggerFileDownload = vi.fn();
|
||||
|
||||
vi.mock("@/lib/utils/file-download", () => ({
|
||||
triggerFileDownload: (...args: unknown[]) => triggerFileDownload(...args),
|
||||
fileBasename: (p: string) => p.split("/").pop() ?? p,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/editors/external-vcs-file-link", () => ({
|
||||
ExternalVcsFileLink: () => <span data-testid="external-vcs-file-link" />,
|
||||
useExternalVcsFileStatus: () => null,
|
||||
}));
|
||||
|
||||
import { FileViewerDownloadButton } from "./file-viewer-header";
|
||||
import { FileBinaryViewer } from "./file-binary-viewer";
|
||||
import { FileImageViewer } from "./file-image-viewer";
|
||||
|
||||
const DOWNLOAD_LABEL = "Download file";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
triggerFileDownload.mockReset();
|
||||
});
|
||||
|
||||
describe("FileViewerDownloadButton", () => {
|
||||
it("renders nothing without a handler", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileViewerDownloadButton />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: DOWNLOAD_LABEL })).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes the handler when activated", () => {
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileViewerDownloadButton onDownload={onDownload} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
screen.getByRole("button", { name: DOWNLOAD_LABEL }).click();
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewer headerActions contract", () => {
|
||||
it("surfaces the download control on the unpreviewable-file screen", () => {
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileBinaryViewer
|
||||
path="assets/bundle.zip"
|
||||
headerActions={<FileViewerDownloadButton onDownload={onDownload} />}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: DOWNLOAD_LABEL }).click();
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces the download control on the image viewer", () => {
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileImageViewer
|
||||
path="assets/logo.png"
|
||||
content="iVBORw0KGgo="
|
||||
headerActions={<FileViewerDownloadButton onDownload={onDownload} />}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: DOWNLOAD_LABEL }).click();
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary download fidelity", () => {
|
||||
it("passes base64 content through as binary so the bytes survive", () => {
|
||||
const content = "iVBORw0KGgo=";
|
||||
const onDownload = () =>
|
||||
triggerFileDownload({ fileName: "assets/logo.png", content, isBinary: true });
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<FileViewerDownloadButton onDownload={onDownload} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
screen.getByRole("button", { name: DOWNLOAD_LABEL }).click();
|
||||
|
||||
expect(triggerFileDownload).toHaveBeenCalledWith({
|
||||
fileName: "assets/logo.png",
|
||||
content,
|
||||
isBinary: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDockviewStore, type FileEditorState } from "@/lib/state/dockview-store";
|
||||
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
|
||||
@@ -98,7 +99,9 @@ describe("FileEditorPanel image preview", () => {
|
||||
it("updates displayed image content when a reused preview tab switches files", async () => {
|
||||
act(() => seedImage("docs/first.png", "first-image"));
|
||||
const { rerender } = render(
|
||||
<FileEditorPanel panelId={PREVIEW_PANEL_ID} params={{ path: "docs/first.png" }} />,
|
||||
<TooltipProvider>
|
||||
<FileEditorPanel panelId={PREVIEW_PANEL_ID} params={{ path: "docs/first.png" }} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("first-image");
|
||||
@@ -107,7 +110,11 @@ describe("FileEditorPanel image preview", () => {
|
||||
});
|
||||
|
||||
act(() => seedImage("docs/second.png", "second-image"));
|
||||
rerender(<FileEditorPanel panelId={PREVIEW_PANEL_ID} params={{ path: "docs/second.png" }} />);
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<FileEditorPanel panelId={PREVIEW_PANEL_ID} params={{ path: "docs/second.png" }} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe("second-image");
|
||||
});
|
||||
@@ -118,19 +125,23 @@ describe("FileEditorPanel image preview", () => {
|
||||
seedImage(SHARED_IMAGE_PATH, REPO_B_CONTENT, "repo-b");
|
||||
});
|
||||
const { rerender } = render(
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe(REPO_A_CONTENT);
|
||||
|
||||
rerender(
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-b" }}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-b" }}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).textContent).toBe(REPO_B_CONTENT);
|
||||
@@ -140,10 +151,12 @@ describe("FileEditorPanel image preview", () => {
|
||||
act(() => seedImage(SHARED_IMAGE_PATH, REPO_A_CONTENT, "repo-a"));
|
||||
|
||||
render(
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const props = JSON.parse(
|
||||
@@ -163,10 +176,12 @@ describe("FileEditorPanel image preview", () => {
|
||||
act(() => seedImage(SHARED_IMAGE_PATH, REPO_A_CONTENT, "repo-a"));
|
||||
|
||||
render(
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<FileEditorPanel
|
||||
panelId={PREVIEW_PANEL_ID}
|
||||
params={{ path: SHARED_IMAGE_PATH, repo: "repo-a" }}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(IMAGE_VIEWER_TEST_ID).getAttribute("data-worktree-path")).toBe(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useRef } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||
import { FileEditorContent } from "./file-editor-content";
|
||||
import { FileEditorContent, type FileEditorContentProps } from "./file-editor-content";
|
||||
import { FileImageViewer } from "./file-image-viewer";
|
||||
import { FileBinaryViewer } from "./file-binary-viewer";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -16,7 +16,8 @@ import { calculateHash } from "@/lib/utils/file-diff";
|
||||
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
|
||||
import { syncOpenFileFromWorkspace } from "@/hooks/file-editors-sync";
|
||||
import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions";
|
||||
import { FileViewerExternalLink } from "./file-viewer-header";
|
||||
import { FileViewerDownloadButton, FileViewerExternalLink } from "./file-viewer-header";
|
||||
import { triggerFileDownload } from "@/lib/utils/file-download";
|
||||
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -64,6 +65,78 @@ type StaticFilePanelProps = {
|
||||
repositoryName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download the open file from the copy already in the dockview store.
|
||||
*
|
||||
* The viewer only renders after `useFileLoader` resolves, so the bytes are
|
||||
* always in hand here and no refetch is needed. Binary content is base64,
|
||||
* matching the `workspace.file.get` contract `triggerFileDownload` expects.
|
||||
*/
|
||||
function useOpenFileDownload(fileKey: string, path: string): (() => void) | undefined {
|
||||
const hasFile = useDockviewStore((s) => s.openFiles.has(fileKey));
|
||||
const content = useDockviewStore((s) => s.openFiles.get(fileKey)?.content ?? "");
|
||||
const isBinary = useDockviewStore((s) => s.openFiles.get(fileKey)?.isBinary ?? false);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!hasFile) return undefined;
|
||||
return () => triggerFileDownload({ fileName: path, content, isBinary });
|
||||
}, [hasFile, path, content, isBinary]);
|
||||
}
|
||||
|
||||
function useLoadedFileDownload(
|
||||
hasFile: boolean,
|
||||
originalHash: string,
|
||||
path: string,
|
||||
content: string,
|
||||
isBinary: boolean,
|
||||
): (() => void) | undefined {
|
||||
return useMemo(
|
||||
() =>
|
||||
hasFile && originalHash
|
||||
? () => triggerFileDownload({ fileName: path, content, isBinary })
|
||||
: undefined,
|
||||
[hasFile, originalHash, path, content, isBinary],
|
||||
);
|
||||
}
|
||||
|
||||
type FileEditorPanelActions = Pick<
|
||||
ReturnType<typeof useFileEditors>,
|
||||
"handleFileChange" | "saveFile" | "deleteFile" | "applyRemoteUpdate"
|
||||
>;
|
||||
|
||||
function useFileEditorPanelActions({
|
||||
path,
|
||||
repo,
|
||||
fileKey,
|
||||
markdownPreview,
|
||||
updateFileState,
|
||||
actions,
|
||||
}: {
|
||||
path: string;
|
||||
repo: string | undefined;
|
||||
fileKey: string;
|
||||
markdownPreview: boolean;
|
||||
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
|
||||
actions: FileEditorPanelActions;
|
||||
}) {
|
||||
const { handleFileChange, saveFile, deleteFile, applyRemoteUpdate } = actions;
|
||||
const onChange = useCallback(
|
||||
(newContent: string) => handleFileChange(path, newContent, repo),
|
||||
[handleFileChange, path, repo],
|
||||
);
|
||||
const onSave = useCallback(() => saveFile(path, repo), [saveFile, path, repo]);
|
||||
const onReloadFromAgent = useCallback(
|
||||
() => applyRemoteUpdate(path, repo),
|
||||
[applyRemoteUpdate, path, repo],
|
||||
);
|
||||
const onDelete = useCallback(() => deleteFile(path, repo), [deleteFile, path, repo]);
|
||||
const onToggleMarkdownPreview = useCallback(
|
||||
() => updateFileState(fileKey, { markdownPreview: !markdownPreview }),
|
||||
[updateFileState, fileKey, markdownPreview],
|
||||
);
|
||||
return { onChange, onSave, onReloadFromAgent, onDelete, onToggleMarkdownPreview };
|
||||
}
|
||||
|
||||
function StaticFilePanel({
|
||||
category,
|
||||
fileKey,
|
||||
@@ -74,14 +147,18 @@ function StaticFilePanel({
|
||||
repositoryId,
|
||||
repositoryName,
|
||||
}: StaticFilePanelProps) {
|
||||
const onDownload = useOpenFileDownload(fileKey, path);
|
||||
const headerActions = (
|
||||
<FileViewerExternalLink
|
||||
path={path}
|
||||
sessionId={sessionId}
|
||||
taskId={taskId}
|
||||
repositoryId={repositoryId}
|
||||
repositoryName={repositoryName}
|
||||
/>
|
||||
<>
|
||||
<FileViewerExternalLink
|
||||
path={path}
|
||||
sessionId={sessionId}
|
||||
taskId={taskId}
|
||||
repositoryId={repositoryId}
|
||||
repositoryName={repositoryName}
|
||||
/>
|
||||
<FileViewerDownloadButton onDownload={onDownload} />
|
||||
</>
|
||||
);
|
||||
if (category === "image") {
|
||||
return (
|
||||
@@ -249,6 +326,7 @@ 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,
|
||||
);
|
||||
@@ -259,6 +337,7 @@ function useFileEditorBuffer(fileKey: string) {
|
||||
hasRemoteUpdate,
|
||||
isBinary,
|
||||
originalContent,
|
||||
originalHash,
|
||||
markdownPreview,
|
||||
};
|
||||
}
|
||||
@@ -278,6 +357,52 @@ function LoadingFilePanel() {
|
||||
);
|
||||
}
|
||||
|
||||
type LoadedFilePanelProps = {
|
||||
category: FileCategory;
|
||||
fileKey: string;
|
||||
path: string;
|
||||
worktreePath: string | undefined;
|
||||
sessionId: string | null;
|
||||
taskId: string | null;
|
||||
repositoryId: string | undefined;
|
||||
repositoryName: string | undefined;
|
||||
editorProps: FileEditorContentProps;
|
||||
};
|
||||
|
||||
function LoadedFilePanel({
|
||||
category,
|
||||
fileKey,
|
||||
path,
|
||||
worktreePath,
|
||||
sessionId,
|
||||
taskId,
|
||||
repositoryId,
|
||||
repositoryName,
|
||||
editorProps,
|
||||
}: LoadedFilePanelProps) {
|
||||
if (category !== "text") {
|
||||
return (
|
||||
<StaticFilePanel
|
||||
category={category}
|
||||
fileKey={fileKey}
|
||||
path={path}
|
||||
worktreePath={worktreePath}
|
||||
sessionId={sessionId}
|
||||
taskId={taskId}
|
||||
repositoryId={repositoryId}
|
||||
repositoryName={repositoryName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<FileEditorContent {...editorProps} />
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export const FileEditorPanel = memo(function FileEditorPanel({
|
||||
panelId,
|
||||
params,
|
||||
@@ -286,8 +411,16 @@ 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, markdownPreview } =
|
||||
useFileEditorBuffer(fileKey);
|
||||
const {
|
||||
hasFile,
|
||||
content,
|
||||
isDirty,
|
||||
hasRemoteUpdate,
|
||||
isBinary,
|
||||
originalContent,
|
||||
originalHash,
|
||||
markdownPreview,
|
||||
} = useFileEditorBuffer(fileKey);
|
||||
const setFileState = useDockviewStore((s) => s.setFileState);
|
||||
const updateFileState = useDockviewStore((s) => s.updateFileState);
|
||||
|
||||
@@ -311,70 +444,63 @@ export const FileEditorPanel = memo(function FileEditorPanel({
|
||||
updateFileState,
|
||||
});
|
||||
|
||||
const onChange = useCallback(
|
||||
(newContent: string) => handleFileChange(path, newContent, repo),
|
||||
[handleFileChange, path, repo],
|
||||
);
|
||||
const onSave = useCallback(() => saveFile(path, repo), [saveFile, path, repo]);
|
||||
const onReloadFromAgent = useCallback(
|
||||
() => applyRemoteUpdate(path, repo),
|
||||
[applyRemoteUpdate, path, repo],
|
||||
);
|
||||
const onDelete = useCallback(() => deleteFile(path, repo), [deleteFile, path, repo]);
|
||||
const onToggleMarkdownPreview = useCallback(
|
||||
() => updateFileState(fileKey, { markdownPreview: !markdownPreview }),
|
||||
[updateFileState, fileKey, markdownPreview],
|
||||
);
|
||||
const { onChange, onSave, onReloadFromAgent, onDelete, onToggleMarkdownPreview } =
|
||||
useFileEditorPanelActions({
|
||||
path,
|
||||
repo,
|
||||
fileKey,
|
||||
markdownPreview,
|
||||
updateFileState,
|
||||
actions: {
|
||||
handleFileChange,
|
||||
saveFile,
|
||||
deleteFile,
|
||||
applyRemoteUpdate,
|
||||
},
|
||||
});
|
||||
const onDownload = useLoadedFileDownload(hasFile, originalHash, path, content, isBinary);
|
||||
|
||||
if (!hasFile) {
|
||||
if (!hasFile || !originalHash) {
|
||||
return <LoadingFilePanel />;
|
||||
}
|
||||
|
||||
const worktreePath = getSessionWorkspacePath(activeSession);
|
||||
const repositoryId = activeSession?.repository_id ?? undefined;
|
||||
const category = resolveFileCategory(isBinary, path);
|
||||
if (category !== "text") {
|
||||
return (
|
||||
<StaticFilePanel
|
||||
category={category}
|
||||
fileKey={fileKey}
|
||||
path={path}
|
||||
worktreePath={worktreePath}
|
||||
sessionId={activeSessionId}
|
||||
taskId={activeTaskId}
|
||||
repositoryId={repositoryId}
|
||||
repositoryName={repo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isMarkdown = isMarkdownFile(path);
|
||||
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<FileEditorContent
|
||||
path={path}
|
||||
content={content}
|
||||
originalContent={originalContent}
|
||||
isDirty={isDirty}
|
||||
hasRemoteUpdate={hasRemoteUpdate}
|
||||
vcsDiff={vcsDiff}
|
||||
isSaving={savingFiles.has(fileKey)}
|
||||
sessionId={activeSessionId || undefined}
|
||||
taskId={activeTaskId}
|
||||
repositoryId={repositoryId}
|
||||
worktreePath={worktreePath}
|
||||
repo={repo}
|
||||
enableComments={!!activeSessionId}
|
||||
markdownPreview={isMarkdown ? markdownPreview : false}
|
||||
onToggleMarkdownPreview={isMarkdown ? onToggleMarkdownPreview : undefined}
|
||||
onChange={onChange}
|
||||
onSave={onSave}
|
||||
onReloadFromAgent={onReloadFromAgent}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
<LoadedFilePanel
|
||||
category={category}
|
||||
fileKey={fileKey}
|
||||
path={path}
|
||||
worktreePath={worktreePath}
|
||||
sessionId={activeSessionId}
|
||||
taskId={activeTaskId}
|
||||
repositoryId={repositoryId}
|
||||
repositoryName={repo}
|
||||
editorProps={{
|
||||
path,
|
||||
content,
|
||||
originalContent,
|
||||
isDirty,
|
||||
hasRemoteUpdate,
|
||||
vcsDiff,
|
||||
isSaving: savingFiles.has(fileKey),
|
||||
sessionId: activeSessionId || undefined,
|
||||
taskId: activeTaskId,
|
||||
repositoryId,
|
||||
worktreePath,
|
||||
repo,
|
||||
enableComments: !!activeSessionId,
|
||||
markdownPreview: isMarkdown ? markdownPreview : false,
|
||||
onToggleMarkdownPreview: isMarkdown ? onToggleMarkdownPreview : undefined,
|
||||
onChange,
|
||||
onSave,
|
||||
onReloadFromAgent,
|
||||
onDelete,
|
||||
onDownload,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FileUploadConflictDialog } from "./file-upload-conflict-dialog";
|
||||
import type { ConflictChoice, PendingConflicts } from "@/hooks/use-file-upload";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const KEEP_BOTH_LABEL = "Keep both";
|
||||
const REPLACE_LABEL = "Replace";
|
||||
const SKIP_LABEL = "Skip";
|
||||
const UPLOAD_LABEL = "Upload";
|
||||
const APPLY_TO_ALL = "Apply to all";
|
||||
const NOT_PRESSED = "false";
|
||||
const ARIA_PRESSED = "aria-pressed";
|
||||
const A_TXT = "fixtures/a.txt";
|
||||
const B_TXT = "fixtures/b.txt";
|
||||
|
||||
function pendingFor(paths: string[]): PendingConflicts {
|
||||
return {
|
||||
conflicts: paths.map((path) => ({ path, is_dir: false })),
|
||||
byDestination: new Map(paths.map((path) => [path, path])),
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog(paths: string[]) {
|
||||
const onResolve = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<FileUploadConflictDialog
|
||||
pending={pendingFor(paths)}
|
||||
onResolve={onResolve}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
return { onResolve, onCancel };
|
||||
}
|
||||
|
||||
function choicesFrom(onResolve: ReturnType<typeof vi.fn>): Map<string, ConflictChoice> {
|
||||
return onResolve.mock.calls[0][0] as Map<string, ConflictChoice>;
|
||||
}
|
||||
|
||||
function clickIn(groupLabel: string, buttonLabel: string) {
|
||||
const group = screen.getByRole("group", { name: groupLabel });
|
||||
fireEvent.click(within(group).getByRole("button", { name: buttonLabel }));
|
||||
}
|
||||
|
||||
describe("FileUploadConflictDialog", () => {
|
||||
it("renders nothing when there is no parked batch", () => {
|
||||
render(<FileUploadConflictDialog pending={null} onResolve={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists every conflicting path", () => {
|
||||
renderDialog([A_TXT, "fixtures/deep/b.json"]);
|
||||
|
||||
expect(screen.getByText(A_TXT)).toBeTruthy();
|
||||
expect(screen.getByText("fixtures/deep/b.json")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("defaults to the non-destructive choice", () => {
|
||||
const { onResolve } = renderDialog([A_TXT]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: UPLOAD_LABEL }));
|
||||
|
||||
expect(onResolve).toHaveBeenCalledTimes(1);
|
||||
expect(choicesFrom(onResolve).get(A_TXT)).toBe("keep_both");
|
||||
});
|
||||
|
||||
it("applies one choice to every remaining conflict", () => {
|
||||
const { onResolve } = renderDialog([A_TXT, B_TXT, "fixtures/c.txt"]);
|
||||
|
||||
clickIn(APPLY_TO_ALL, REPLACE_LABEL);
|
||||
fireEvent.click(screen.getByRole("button", { name: UPLOAD_LABEL }));
|
||||
|
||||
expect([...choicesFrom(onResolve).values()]).toEqual(["replace", "replace", "replace"]);
|
||||
});
|
||||
|
||||
it("keeps a per-file override after an apply-to-all", () => {
|
||||
const { onResolve } = renderDialog([A_TXT, B_TXT]);
|
||||
|
||||
clickIn(APPLY_TO_ALL, SKIP_LABEL);
|
||||
clickIn(A_TXT, KEEP_BOTH_LABEL);
|
||||
fireEvent.click(screen.getByRole("button", { name: UPLOAD_LABEL }));
|
||||
|
||||
const choices = choicesFrom(onResolve);
|
||||
expect(choices.get(A_TXT)).toBe("keep_both");
|
||||
expect(choices.get(B_TXT)).toBe("skip");
|
||||
});
|
||||
|
||||
it("does not show an apply-to-all choice when per-file choices differ", () => {
|
||||
renderDialog([A_TXT, B_TXT]);
|
||||
|
||||
clickIn(A_TXT, REPLACE_LABEL);
|
||||
|
||||
const applyGroup = screen.getByRole("group", { name: APPLY_TO_ALL });
|
||||
expect(
|
||||
within(applyGroup).getByRole("button", { name: KEEP_BOTH_LABEL }).getAttribute(ARIA_PRESSED),
|
||||
).toBe(NOT_PRESSED);
|
||||
expect(
|
||||
within(applyGroup).getByRole("button", { name: REPLACE_LABEL }).getAttribute(ARIA_PRESSED),
|
||||
).toBe(NOT_PRESSED);
|
||||
});
|
||||
|
||||
it("marks the active choice for assistive technology", () => {
|
||||
renderDialog([A_TXT]);
|
||||
|
||||
const group = screen.getByRole("group", { name: A_TXT });
|
||||
expect(
|
||||
within(group).getByRole("button", { name: KEEP_BOTH_LABEL }).getAttribute(ARIA_PRESSED),
|
||||
).toBe("true");
|
||||
|
||||
clickIn(A_TXT, SKIP_LABEL);
|
||||
expect(within(group).getByRole("button", { name: SKIP_LABEL }).getAttribute(ARIA_PRESSED)).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports cancellation without resolving anything", () => {
|
||||
const { onResolve, onCancel } = renderDialog([A_TXT]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onResolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConflictChoice, PendingConflicts } from "@/hooks/use-file-upload";
|
||||
|
||||
// Persisted decisions that cross the wire, never displayed.
|
||||
const KEEP_BOTH: ConflictChoice = "keep_both"; // i18n-exempt: wire value
|
||||
const REPLACE: ConflictChoice = "replace"; // i18n-exempt: wire value
|
||||
const SKIP: ConflictChoice = "skip"; // i18n-exempt: wire value
|
||||
|
||||
// Keep both leads because it is the only non-destructive option.
|
||||
const CHOICES: ConflictChoice[] = [KEEP_BOTH, REPLACE, SKIP];
|
||||
|
||||
type FileUploadConflictDialogProps = {
|
||||
pending: PendingConflicts | null;
|
||||
onResolve: (choices: Map<string, ConflictChoice>) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function ChoiceGroup({
|
||||
labels,
|
||||
value,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
labels: Record<ConflictChoice, string>;
|
||||
value?: ConflictChoice;
|
||||
ariaLabel: string;
|
||||
onChange: (choice: ConflictChoice) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={ariaLabel} className="flex shrink-0 gap-1">
|
||||
{CHOICES.map((choice) => (
|
||||
<Button
|
||||
key={choice}
|
||||
size="sm"
|
||||
variant={value === choice ? "default" : "outline"}
|
||||
aria-pressed={value === choice}
|
||||
className={cn(
|
||||
"min-h-11 h-8 cursor-pointer px-2 text-xs sm:min-h-8",
|
||||
choice === REPLACE && "hover:text-destructive",
|
||||
)}
|
||||
onClick={() => onChange(choice)}
|
||||
>
|
||||
{labels[choice]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects a per-file decision for every conflicting destination before any
|
||||
* byte is uploaded.
|
||||
*
|
||||
* Keep both is the default because it is the only non-destructive option;
|
||||
* Replace is irreversible and there is no version history behind it.
|
||||
*/
|
||||
export function FileUploadConflictDialog({
|
||||
pending,
|
||||
onResolve,
|
||||
onCancel,
|
||||
}: FileUploadConflictDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [choices, setChoices] = useState<Map<string, ConflictChoice>>(new Map());
|
||||
|
||||
const labels = useMemo<Record<ConflictChoice, string>>(
|
||||
() => ({
|
||||
keep_both: t("task:uploadConflictKeepBoth"),
|
||||
replace: t("task:uploadConflictReplace"),
|
||||
skip: t("task:uploadConflictSkip"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const paths = useMemo(
|
||||
() => (pending ? pending.conflicts.map((conflict) => conflict.path) : []),
|
||||
[pending],
|
||||
);
|
||||
|
||||
const choiceFor = useCallback(
|
||||
(path: string): ConflictChoice => choices.get(path) ?? KEEP_BOTH,
|
||||
[choices],
|
||||
);
|
||||
|
||||
const applyToAllChoice = useMemo(() => {
|
||||
if (paths.length === 0) return KEEP_BOTH;
|
||||
const first = choiceFor(paths[0]);
|
||||
return paths.every((path) => choiceFor(path) === first) ? first : undefined;
|
||||
}, [paths, choiceFor]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
onResolve(new Map(paths.map((path) => [path, choices.get(path) ?? KEEP_BOTH])));
|
||||
setChoices(new Map());
|
||||
}, [onResolve, paths, choices]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setChoices(new Map());
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("task:uploadConflictTitle", { count: paths.length })}</DialogTitle>
|
||||
<DialogDescription>{t("task:uploadConflictBody")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-b border-foreground/10 pb-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("task:uploadConflictApplyToAll")}
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
labels={labels}
|
||||
value={applyToAllChoice}
|
||||
ariaLabel={t("task:uploadConflictApplyToAll")}
|
||||
onChange={(choice) => setChoices(new Map(paths.map((path) => [path, choice])))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{paths.map((path) => (
|
||||
<li key={path} className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
labels={labels}
|
||||
value={choiceFor(path)}
|
||||
ariaLabel={path}
|
||||
onChange={(choice) => setChoices((prev) => new Map(prev).set(path, choice))}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 cursor-pointer sm:min-h-9"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="min-h-11 cursor-pointer sm:min-h-9"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{t("task:uploadConflictConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { UploadItem, UploadStatus } from "@/hooks/use-file-upload";
|
||||
|
||||
function uploadStatusLabel(status: UploadStatus, t: TFunction): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return t("common:pending");
|
||||
case "blocked":
|
||||
return t("task:blocked");
|
||||
case "uploading":
|
||||
return t("task:loading");
|
||||
case "ready":
|
||||
return t("task:done");
|
||||
case "failed":
|
||||
return t("task:failed");
|
||||
}
|
||||
}
|
||||
|
||||
export function FileUploadStatusList({ uploads }: { uploads: UploadItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (uploads.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="border-b border-foreground/10 px-3 py-2 text-xs"
|
||||
data-testid="files-upload-status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{uploads.map((upload) => (
|
||||
<div key={upload.id} className="flex min-w-0 items-start gap-2">
|
||||
<span className="min-w-0 flex-1 truncate" title={upload.destinationPath}>
|
||||
{upload.destinationPath}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{uploadStatusLabel(upload.status, t)}
|
||||
</span>
|
||||
{upload.error && (
|
||||
<span className="max-w-[40%] truncate text-destructive" title={upload.error}>
|
||||
{upload.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { toRelativePath } from "@/lib/utils";
|
||||
import {
|
||||
ExternalVcsFileLink,
|
||||
@@ -63,3 +67,34 @@ export function FileViewerExternalLink({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FileViewerDownloadButtonProps = {
|
||||
onDownload?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download control for the viewer header. Shared by the binary and image
|
||||
* viewers so both screens, and the mobile viewer that renders the same
|
||||
* `headerActions`, gain the action from one place.
|
||||
*/
|
||||
export function FileViewerDownloadButton({ onDownload }: FileViewerDownloadButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!onDownload) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDownload}
|
||||
aria-label={t("editors:downloadFile")}
|
||||
className="h-11 w-11 p-0 cursor-pointer sm:h-8 sm:w-8"
|
||||
>
|
||||
<IconDownload className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("editors:downloadFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { ResizableMarkdownTable } from "@/components/shared/resizable-markdown-table";
|
||||
import { cn, toRelativePath } from "@/lib/utils";
|
||||
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
||||
import { FileViewerDownloadButton } from "@/components/task/file-viewer-header";
|
||||
import { EditorCommentPopover } from "@/components/task/editor-comment-popover";
|
||||
import { CommentViewPopover } from "@/components/task/comment-view-popover";
|
||||
import { useMarkdownPreviewComments } from "@/hooks/domains/comments/use-markdown-preview-comments";
|
||||
@@ -54,6 +55,7 @@ interface MarkdownPreviewToolbarProps {
|
||||
repositoryId?: string | null;
|
||||
repositoryName?: string;
|
||||
showExternalVcsLink: boolean;
|
||||
onDownload?: () => void;
|
||||
onTogglePreview: () => void;
|
||||
}
|
||||
|
||||
@@ -67,6 +69,7 @@ function MarkdownPreviewToolbar({
|
||||
repositoryId,
|
||||
repositoryName,
|
||||
showExternalVcsLink,
|
||||
onDownload,
|
||||
onTogglePreview,
|
||||
}: MarkdownPreviewToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -93,6 +96,7 @@ function MarkdownPreviewToolbar({
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<FileViewerDownloadButton onDownload={onDownload} />
|
||||
{commentsEnabled && commentCount > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
|
||||
<IconMessagePlus className="h-3.5 w-3.5" />
|
||||
@@ -105,7 +109,7 @@ function MarkdownPreviewToolbar({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onTogglePreview}
|
||||
className="h-8 w-8 p-0 cursor-pointer text-foreground"
|
||||
className="h-11 w-11 p-0 cursor-pointer text-foreground sm:h-8 sm:w-8"
|
||||
data-testid="markdown-preview-toggle"
|
||||
>
|
||||
<IconCode className="h-4 w-4" />
|
||||
@@ -129,6 +133,7 @@ interface MarkdownPreviewContentProps {
|
||||
repositoryName?: string;
|
||||
enableComments?: boolean;
|
||||
showExternalVcsLink?: boolean;
|
||||
onDownload?: () => void;
|
||||
onTogglePreview: () => void;
|
||||
}
|
||||
|
||||
@@ -380,6 +385,7 @@ export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
|
||||
repositoryName,
|
||||
enableComments = false,
|
||||
showExternalVcsLink = true,
|
||||
onDownload,
|
||||
onTogglePreview,
|
||||
}: MarkdownPreviewContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -432,6 +438,7 @@ export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
|
||||
repositoryId={repositoryId}
|
||||
repositoryName={repositoryName}
|
||||
showExternalVcsLink={showExternalVcsLink}
|
||||
onDownload={onDownload}
|
||||
onTogglePreview={onTogglePreview}
|
||||
/>
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto p-6">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = {
|
||||
@@ -47,19 +48,21 @@ afterEach(cleanup);
|
||||
describe("MobileFileViewerPanel workspace path", () => {
|
||||
it("uses the effective workspace path for binary file viewers", () => {
|
||||
render(
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "dist/archive.zip",
|
||||
name: "archive.zip",
|
||||
content: "",
|
||||
originalContent: "",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
isBinary: true,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "dist/archive.zip",
|
||||
name: "archive.zip",
|
||||
content: "",
|
||||
originalContent: "",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
isBinary: true,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("binary-viewer").getAttribute("data-worktree-path")).toBe(
|
||||
@@ -71,19 +74,21 @@ describe("MobileFileViewerPanel workspace path", () => {
|
||||
describe("MobileFileViewerPanel external file action", () => {
|
||||
it("renders a touch-sized action scoped to the open file's repository", () => {
|
||||
render(
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "src/new.ts",
|
||||
name: "new.ts",
|
||||
repo: "frontend",
|
||||
content: "",
|
||||
originalContent: "",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "src/new.ts",
|
||||
name: "new.ts",
|
||||
repo: "frontend",
|
||||
content: "",
|
||||
originalContent: "",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const props = JSON.parse(
|
||||
@@ -101,19 +106,21 @@ describe("MobileFileViewerPanel external file action", () => {
|
||||
|
||||
it("opens a Markdown file directly in preview mode when requested", () => {
|
||||
render(
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "README.md",
|
||||
name: "README.md",
|
||||
content: "# README",
|
||||
originalContent: "# README",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
initialMarkdownPreview
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "README.md",
|
||||
name: "README.md",
|
||||
content: "# README",
|
||||
originalContent: "# README",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
initialMarkdownPreview
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("markdown-preview")).toBeTruthy();
|
||||
@@ -122,38 +129,42 @@ describe("MobileFileViewerPanel external file action", () => {
|
||||
|
||||
it("resets preview mode when the same path is opened from another repository", () => {
|
||||
const { rerender } = render(
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "README.md",
|
||||
name: "README.md",
|
||||
repo: "frontend",
|
||||
content: "# README",
|
||||
originalContent: "# README",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "README.md",
|
||||
name: "README.md",
|
||||
repo: "frontend",
|
||||
content: "# README",
|
||||
originalContent: "# README",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("markdown-preview-toggle"));
|
||||
expect(screen.getByTestId("markdown-preview")).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "README.md",
|
||||
name: "README.md",
|
||||
repo: "backend",
|
||||
content: "# README",
|
||||
originalContent: "# README",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<MobileFileViewerPanel
|
||||
file={{
|
||||
path: "README.md",
|
||||
name: "README.md",
|
||||
repo: "backend",
|
||||
content: "# README",
|
||||
originalContent: "# README",
|
||||
originalHash: "hash",
|
||||
isDirty: false,
|
||||
}}
|
||||
sessionId="session-1"
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("file-content")).toBeTruthy();
|
||||
|
||||
@@ -8,7 +8,9 @@ import { FileViewerContent } from "../file-viewer-content";
|
||||
import { MarkdownPreviewContent } from "../markdown-preview-content";
|
||||
import { FileImageViewer } from "../file-image-viewer";
|
||||
import { FileBinaryViewer } from "../file-binary-viewer";
|
||||
import { FileViewerDownloadButton } from "../file-viewer-header";
|
||||
import { getFileCategory, isMarkdownFile } from "@/lib/utils/file-types";
|
||||
import { triggerFileDownload } from "@/lib/utils/file-download";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { OpenFileTab } from "@/lib/types/backend";
|
||||
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
|
||||
@@ -99,6 +101,15 @@ export function MobileFileViewerPanel({
|
||||
const fileStatus = useExternalVcsFileStatus(file.path, sessionId, file.repo);
|
||||
const viewerKind = useMemo(() => resolveViewerKind(file), [file]);
|
||||
const markdownFile = isMarkdownFile(file.path);
|
||||
const onDownload = useMemo(
|
||||
() => () =>
|
||||
triggerFileDownload({
|
||||
fileName: file.path,
|
||||
content: file.content,
|
||||
isBinary: !!file.isBinary,
|
||||
}),
|
||||
[file.path, file.content, file.isBinary],
|
||||
);
|
||||
|
||||
const [markdownPreview, setMarkdownPreview] = useState(initialMarkdownPreview);
|
||||
const fileIdentity = `${file.repo ?? ""}\u0000${file.path}`;
|
||||
@@ -129,6 +140,7 @@ export function MobileFileViewerPanel({
|
||||
repositoryName={file.repo}
|
||||
size="touch"
|
||||
/>
|
||||
<FileViewerDownloadButton onDownload={onDownload} />
|
||||
{markdownFile && !markdownPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useFileUpload, type UploadFilesResult } from "@/hooks/use-file-upload";
|
||||
import { FileUploadConflictDialog } from "./file-upload-conflict-dialog";
|
||||
|
||||
export type UploadPickerMode = "files" | "folder";
|
||||
|
||||
/**
|
||||
* Owns the two hidden file inputs, the conflict dialog, and the result toasts,
|
||||
* so both Files panels get the same upload behavior from one place.
|
||||
*
|
||||
* The picked destination is captured when the picker opens, not when it
|
||||
* resolves, because the active folder can change while the OS dialog is up.
|
||||
*/
|
||||
export function useFileUploadEntryPoints(sessionId: string | null) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const filesInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const destinationRef = useRef<string>("");
|
||||
const pickerSessionRef = useRef<string | null>(sessionId);
|
||||
const [, setPickerOpen] = useState(false);
|
||||
|
||||
const { uploads, conflicts, uploadFiles, resolveConflicts, cancelConflicts } =
|
||||
useFileUpload(sessionId);
|
||||
|
||||
const report = useCallback(
|
||||
(result: UploadFilesResult) => {
|
||||
if (result.cancelled) return;
|
||||
if (result.uploaded.length > 0) {
|
||||
toast({
|
||||
title: t("task:uploadComplete", { count: result.uploaded.length }),
|
||||
description:
|
||||
result.uploaded.length === 1
|
||||
? t("task:uploadedTo", { path: result.uploaded[0].path })
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (result.failed > 0) {
|
||||
toast({
|
||||
title: t("task:uploadPartiallyFailed", { count: result.failed }),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[t, toast],
|
||||
);
|
||||
|
||||
const openPicker = useCallback(
|
||||
(mode: UploadPickerMode, destination: string) => {
|
||||
destinationRef.current = destination;
|
||||
pickerSessionRef.current = sessionId;
|
||||
const input = mode === "folder" ? folderInputRef.current : filesInputRef.current;
|
||||
if (!input) return;
|
||||
// Reset first, or picking the same file twice in a row fires no change event.
|
||||
input.value = "";
|
||||
setPickerOpen(true);
|
||||
input.click();
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const handlePicked = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
setPickerOpen(false);
|
||||
if (!files || files.length === 0) return;
|
||||
if (pickerSessionRef.current !== sessionId) return;
|
||||
report(await uploadFiles(destinationRef.current, files));
|
||||
},
|
||||
[sessionId, uploadFiles, report],
|
||||
);
|
||||
|
||||
const handleResolve = useCallback(
|
||||
(choices: Parameters<typeof resolveConflicts>[0]) => void resolveConflicts(choices),
|
||||
[resolveConflicts],
|
||||
);
|
||||
|
||||
const elements = (
|
||||
<>
|
||||
<input
|
||||
ref={filesInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
data-testid="files-upload-input"
|
||||
onChange={handlePicked}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
data-testid="files-upload-folder-input"
|
||||
// webkitdirectory is non-standard but universally supported in the
|
||||
// browsers and webviews Kandev targets.
|
||||
{...({ webkitdirectory: "" } as Record<string, string>)}
|
||||
onChange={handlePicked}
|
||||
/>
|
||||
<FileUploadConflictDialog
|
||||
pending={conflicts}
|
||||
onResolve={handleResolve}
|
||||
onCancel={cancelConflicts}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return { openPicker, elements, uploads };
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export async function assertMixedAttachmentLayout({
|
||||
const textPath = path.join(testInfo.outputDir, "notes.txt");
|
||||
fs.writeFileSync(textPath, "plain text attachment");
|
||||
|
||||
const fileInput = testPage.locator('input[type="file"]');
|
||||
const fileInput = testPage.getByTestId("chat-input-editor-shell").locator('input[type="file"]');
|
||||
// The chat input appends each change event to React attachment state.
|
||||
await fileInput.setInputFiles(imagePath);
|
||||
const imagePreview = testPage.getByText(/Image \(/);
|
||||
|
||||
@@ -86,7 +86,8 @@ test.describe("chat attachment delivery mode", () => {
|
||||
),
|
||||
);
|
||||
|
||||
await testPage.locator('input[type="file"]').setInputFiles(imagePath);
|
||||
const fileInput = testPage.getByTestId("chat-input-editor-shell").locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(imagePath);
|
||||
const imageChip = testPage.getByText(/Image \(/).first();
|
||||
await expect(imageChip).toBeVisible({ timeout: 10_000 });
|
||||
await imageChip.hover();
|
||||
@@ -116,7 +117,7 @@ test.describe("chat attachment delivery mode", () => {
|
||||
const textPath = path.join(testInfo.outputDir, "notes.txt");
|
||||
fs.writeFileSync(textPath, "plain text attachment");
|
||||
|
||||
await testPage.locator('input[type="file"]').setInputFiles(textPath);
|
||||
await fileInput.setInputFiles(textPath);
|
||||
await expect(testPage.getByText("notes.txt", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,10 @@ test.describe.serial("mid-turn attachment delivery", () => {
|
||||
const attachmentPath = path.join(testInfo.outputDir, ATTACHMENT_NAME);
|
||||
fs.writeFileSync(attachmentPath, "mid-turn attachment bytes");
|
||||
|
||||
await testPage.locator('input[type="file"]').setInputFiles(attachmentPath);
|
||||
await testPage
|
||||
.getByTestId("chat-input-editor-shell")
|
||||
.locator('input[type="file"]')
|
||||
.setInputFiles(attachmentPath);
|
||||
await expect(testPage.getByText(ATTACHMENT_NAME, { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,13 @@ test.describe("SSH LSP boundary", () => {
|
||||
// reachable from the remote container. Create the Kotlin file through the
|
||||
// real workspace WebSocket so the assertion exercises the SSH task host.
|
||||
await task.session.clickTab("Files");
|
||||
await testPage.getByRole("button", { name: "New file" }).click();
|
||||
const directNewFile = testPage.getByRole("button", { name: "New file" });
|
||||
if (await directNewFile.isVisible().catch(() => false)) {
|
||||
await directNewFile.click();
|
||||
} else {
|
||||
await testPage.getByTestId("files-create-menu").click();
|
||||
await testPage.getByRole("menuitem", { name: "New file" }).click();
|
||||
}
|
||||
const fileNameInput = testPage.getByPlaceholder("filename...");
|
||||
await expect(fileNameInput).toBeVisible();
|
||||
await fileNameInput.fill(remoteFilePath);
|
||||
|
||||
@@ -42,8 +42,16 @@ async function setupTask(
|
||||
|
||||
async function startCreateAtRoot(testPage: Page) {
|
||||
const btn = testPage.getByRole("button", { name: "New file" });
|
||||
await expect(btn).toBeVisible({ timeout: 15_000 });
|
||||
await btn.click();
|
||||
if (await btn.isVisible().catch(() => false)) {
|
||||
await btn.click();
|
||||
} else {
|
||||
const createMenu = testPage.getByTestId("files-create-menu");
|
||||
await expect(createMenu).toBeVisible({ timeout: 15_000 });
|
||||
await createMenu.click();
|
||||
const menuItem = testPage.getByRole("menuitem", { name: "New file" });
|
||||
await expect(menuItem).toBeVisible({ timeout: 5_000 });
|
||||
await menuItem.click();
|
||||
}
|
||||
const input = testPage.getByPlaceholder("filename...");
|
||||
await expect(input).toBeVisible({ timeout: 5_000 });
|
||||
await expect(input).toBeFocused({ timeout: 2_000 });
|
||||
@@ -154,9 +162,7 @@ test.describe("File tree create file", () => {
|
||||
await folder.click();
|
||||
await expect(session.fileTreeNode("scope/existing.ts")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.getByRole("button", { name: "New file" }).click();
|
||||
const input = testPage.getByPlaceholder("filename...");
|
||||
await expect(input).toBeVisible({ timeout: 5_000 });
|
||||
const input = await startCreateAtRoot(testPage);
|
||||
await input.fill("inside.ts");
|
||||
await input.press("Enter");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Regression guard: tapping a file in the Files → All files tab must replace
|
||||
// the Files panel with the file viewer, NOT navigate to the Changes panel.
|
||||
import { type Page } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
@@ -86,7 +87,13 @@ test.describe("Mobile file tree keyboard shortcuts", () => {
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
await expect(session.fileTreeNode(filePath)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await testPage.getByRole("button", { name: "New file" }).tap();
|
||||
const directNewFile = testPage.getByRole("button", { name: "New file" });
|
||||
if (await directNewFile.isVisible().catch(() => false)) {
|
||||
await directNewFile.tap();
|
||||
} else {
|
||||
await testPage.getByTestId("files-create-menu").tap();
|
||||
await testPage.getByRole("menuitem", { name: "New file" }).tap();
|
||||
}
|
||||
const input = testPage.getByPlaceholder("filename...");
|
||||
await expect(input).toBeFocused({ timeout: 5_000 });
|
||||
|
||||
@@ -202,6 +209,76 @@ test.describe("Mobile file viewer panel", () => {
|
||||
await expect(viewer.getByText("Binary file")).toBeVisible();
|
||||
});
|
||||
|
||||
test("binary viewer exposes a touch-sized download control", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const binary = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]);
|
||||
const { session, filePath } = await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Binary Download",
|
||||
options: { extension: "bin", content: binary },
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
await session.fileTreeNode(filePath).tap();
|
||||
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
|
||||
await expect(viewer).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const downloadControl = viewer.getByRole("button", { name: "Download file" });
|
||||
await expect(downloadControl).toBeVisible();
|
||||
const box = await downloadControl.boundingBox();
|
||||
expect(box?.height).toBeGreaterThanOrEqual(44);
|
||||
expect(box?.width).toBeGreaterThanOrEqual(44);
|
||||
|
||||
const download = testPage.waitForEvent("download");
|
||||
await downloadControl.tap();
|
||||
const saved = await download;
|
||||
const target = path.join(backend.tmpDir, "mobile-file-download.bin");
|
||||
await saved.saveAs(target);
|
||||
expect(fs.readFileSync(target)).toEqual(binary);
|
||||
});
|
||||
|
||||
test("Files upload menu keeps its actions touch-sized", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await setupMobileFileViewerTest({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Upload Menu",
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
const createMenu = testPage.getByTestId("files-create-menu");
|
||||
const triggerBox = await createMenu.boundingBox();
|
||||
expect(triggerBox?.height).toBeGreaterThanOrEqual(44);
|
||||
expect(triggerBox?.width).toBeGreaterThanOrEqual(44);
|
||||
await createMenu.tap();
|
||||
|
||||
const uploadFiles = testPage.getByRole("menuitem", { name: "Upload files" });
|
||||
const uploadFolder = testPage.getByRole("menuitem", { name: "Upload folder" });
|
||||
await expect(uploadFiles).toBeVisible();
|
||||
await expect(uploadFolder).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await uploadFiles.boundingBox())?.height ?? 0, { timeout: 5_000 })
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
await expect
|
||||
.poll(async () => (await uploadFolder.boundingBox())?.height ?? 0, { timeout: 5_000 })
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
test("viewer panel keeps a visible close action and supports scrolling large files", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import {
|
||||
GitHelper,
|
||||
makeGitEnv,
|
||||
openTaskSession,
|
||||
createStandardProfile,
|
||||
} from "../../helpers/git-helper";
|
||||
|
||||
/**
|
||||
* End-to-end coverage for workspace file transfer.
|
||||
*
|
||||
* Drag and drop from the operating system is deliberately absent: it is out of
|
||||
* scope for this capability, and Playwright cannot drive an OS-level drag
|
||||
* reliably anyway. A test that appeared to cover it would be worse than none.
|
||||
*/
|
||||
|
||||
const UPLOAD_INPUT = '[data-testid="files-upload-input"]';
|
||||
const FOLDER_INPUT = '[data-testid="files-upload-folder-input"]';
|
||||
const UPLOAD_ROUTE = /\/api\/v1\/task-sessions\/[^/]+\/workspace\/files$/;
|
||||
const PREFLIGHT_ROUTE = /\/api\/v1\/task-sessions\/[^/]+\/workspace\/files\/preflight$/;
|
||||
|
||||
async function openFilesPanel(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
|
||||
profileName: string,
|
||||
taskTitle: string,
|
||||
) {
|
||||
const profile = await createStandardProfile(apiClient, profileName);
|
||||
await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const session = await openTaskSession(testPage, taskTitle);
|
||||
await session.clickTab("Files");
|
||||
return session;
|
||||
}
|
||||
|
||||
function seedRepo(backend: { tmpDir: string }, files: Record<string, string | Buffer>) {
|
||||
const git = new GitHelper(
|
||||
path.join(backend.tmpDir, "repos", "e2e-repo"),
|
||||
makeGitEnv(backend.tmpDir),
|
||||
);
|
||||
for (const [name, content] of Object.entries(files)) git.createFile(name, content);
|
||||
git.stageAll();
|
||||
git.commit("seed");
|
||||
}
|
||||
|
||||
test.describe("Workspace file transfer", () => {
|
||||
test("uploads picked files and shows them in the tree", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
seedRepo(backend, { "existing.ts": "seed" });
|
||||
|
||||
const session = await openFilesPanel(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"wft-upload-1",
|
||||
"WFT Upload Files",
|
||||
);
|
||||
await expect(session.fileTreeNode("existing.ts")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const uploaded = waitForHttp(testPage, "POST", UPLOAD_ROUTE, {
|
||||
predicate: (response) => response.status() === 201,
|
||||
});
|
||||
await testPage.setInputFiles(UPLOAD_INPUT, {
|
||||
name: "fixture.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from('{"ok":true}'),
|
||||
});
|
||||
await uploaded;
|
||||
|
||||
await expect(session.fileTreeNode("fixture.json")).toBeVisible();
|
||||
});
|
||||
|
||||
test("prompts on a name collision and keeps both copies", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
seedRepo(backend, { "taken.txt": "original" });
|
||||
|
||||
const session = await openFilesPanel(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"wft-conflict-1",
|
||||
"WFT Upload Conflict",
|
||||
);
|
||||
await expect(session.fileTreeNode("taken.txt")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const preflighted = waitForHttp(testPage, "POST", PREFLIGHT_ROUTE);
|
||||
await testPage.setInputFiles(UPLOAD_INPUT, {
|
||||
name: "taken.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("incoming"),
|
||||
});
|
||||
await preflighted;
|
||||
|
||||
// The dialog appears before anything is written.
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("taken.txt")).toBeVisible();
|
||||
|
||||
const uploaded = waitForHttp(testPage, "POST", UPLOAD_ROUTE, {
|
||||
predicate: (response) => response.status() === 201,
|
||||
});
|
||||
await dialog.getByRole("button", { name: "Upload" }).click();
|
||||
await uploaded;
|
||||
|
||||
// Keep both is the default, so the original survives beside the new copy.
|
||||
await expect(session.fileTreeNode("taken-1.txt")).toBeVisible();
|
||||
await expect(session.fileTreeNode("taken.txt")).toBeVisible();
|
||||
});
|
||||
|
||||
test("cancelling the conflict prompt writes nothing at all", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
seedRepo(backend, { "taken.txt": "original" });
|
||||
|
||||
const session = await openFilesPanel(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"wft-cancel-1",
|
||||
"WFT Upload Cancel",
|
||||
);
|
||||
await expect(session.fileTreeNode("taken.txt")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
let uploadAttempted = false;
|
||||
testPage.on("request", (request) => {
|
||||
if (request.method() === "POST" && UPLOAD_ROUTE.test(new URL(request.url()).pathname)) {
|
||||
uploadAttempted = true;
|
||||
}
|
||||
});
|
||||
|
||||
const preflighted = waitForHttp(testPage, "POST", PREFLIGHT_ROUTE);
|
||||
await testPage.setInputFiles(UPLOAD_INPUT, [
|
||||
{ name: "taken.txt", mimeType: "text/plain", buffer: Buffer.from("incoming") },
|
||||
{ name: "fresh.txt", mimeType: "text/plain", buffer: Buffer.from("new") },
|
||||
]);
|
||||
await preflighted;
|
||||
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
// The assertion that matters is an absence: the unconflicted file in the
|
||||
// same selection must not have been written either.
|
||||
expect(uploadAttempted).toBe(false);
|
||||
await expect(session.fileTreeNode("fresh.txt")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("uploads a folder and recreates its structure", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
seedRepo(backend, { "existing.ts": "seed" });
|
||||
|
||||
const session = await openFilesPanel(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"wft-folder-1",
|
||||
"WFT Upload Folder",
|
||||
);
|
||||
await expect(session.fileTreeNode("existing.ts")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const uploaded = waitForHttp(testPage, "POST", UPLOAD_ROUTE, {
|
||||
predicate: (response) => response.status() === 201,
|
||||
});
|
||||
// A webkitdirectory input takes a directory path, not file descriptors:
|
||||
// Playwright derives each webkitRelativePath from the tree on disk.
|
||||
const bundleDir = path.join(backend.tmpDir, "upload-bundle");
|
||||
fs.mkdirSync(path.join(bundleDir, "nested"), { recursive: true });
|
||||
fs.writeFileSync(path.join(bundleDir, "nested", "leaf.txt"), "leaf");
|
||||
await testPage.setInputFiles(FOLDER_INPUT, bundleDir);
|
||||
await uploaded;
|
||||
|
||||
await expect(session.fileTreeNode("upload-bundle")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("downloads an unpreviewable file from its viewer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
seedRepo(backend, {
|
||||
"archive.zip": Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02, 0xff, 0xfe]),
|
||||
});
|
||||
|
||||
const session = await openFilesPanel(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"wft-download-1",
|
||||
"WFT Download Binary",
|
||||
);
|
||||
|
||||
const node = session.fileTreeNode("archive.zip");
|
||||
await expect(node).toBeVisible({ timeout: 15_000 });
|
||||
await node.click();
|
||||
|
||||
const downloadControl = testPage.getByRole("button", { name: "Download file" });
|
||||
await expect(downloadControl).toBeVisible();
|
||||
|
||||
const download = testPage.waitForEvent("download");
|
||||
await downloadControl.click();
|
||||
const saved = await download;
|
||||
expect(saved.suggestedFilename()).toBe("archive.zip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const preflightWorkspaceUpload = vi.fn();
|
||||
const uploadWorkspaceFile = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/domains/workspace-file-api", () => ({
|
||||
preflightWorkspaceUpload: (...args: unknown[]) => preflightWorkspaceUpload(...args),
|
||||
uploadWorkspaceFile: (...args: unknown[]) => uploadWorkspaceFile(...args),
|
||||
}));
|
||||
|
||||
import { useFileUpload, type ConflictChoice } from "./use-file-upload";
|
||||
|
||||
const A_TXT = "a.txt";
|
||||
const B_TXT = "b.txt";
|
||||
const C_TXT = "c.txt";
|
||||
const FIXTURES = "fixtures";
|
||||
|
||||
function file(name: string): File {
|
||||
return new File(["bytes"], name);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
preflightWorkspaceUpload.mockReset();
|
||||
uploadWorkspaceFile.mockReset();
|
||||
preflightWorkspaceUpload.mockResolvedValue([]);
|
||||
uploadWorkspaceFile.mockImplementation(
|
||||
async ({ dir, relativePath }: { dir: string; relativePath: string }) => ({
|
||||
path: dir ? `${dir}/${relativePath}` : relativePath,
|
||||
size_bytes: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useFileUpload with no conflicts", () => {
|
||||
it("uploads every file without prompting", async () => {
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
let outcome: Awaited<ReturnType<typeof result.current.uploadFiles>> | undefined;
|
||||
await act(async () => {
|
||||
outcome = await result.current.uploadFiles(FIXTURES, [file(A_TXT), file(B_TXT)]);
|
||||
});
|
||||
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(uploadWorkspaceFile).toHaveBeenCalledTimes(2);
|
||||
expect(outcome?.uploaded.map((u) => u.path)).toEqual([
|
||||
`${FIXTURES}/${A_TXT}`,
|
||||
`${FIXTURES}/${B_TXT}`,
|
||||
]);
|
||||
expect(outcome?.cancelled).toBe(false);
|
||||
await waitFor(() =>
|
||||
expect(result.current.uploads.every((u) => u.status === "ready")).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no resolution when nothing conflicts, so a silent overwrite is impossible", async () => {
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles("", [file(A_TXT)]);
|
||||
});
|
||||
|
||||
expect(uploadWorkspaceFile.mock.calls[0][0]).toMatchObject({ resolution: undefined });
|
||||
});
|
||||
|
||||
it("does nothing without an active session", async () => {
|
||||
const { result } = renderHook(() => useFileUpload(null));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles("", [file(A_TXT)]);
|
||||
});
|
||||
|
||||
expect(preflightWorkspaceUpload).not.toHaveBeenCalled();
|
||||
expect(uploadWorkspaceFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports skipped selections instead of dropping them", async () => {
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
const invalid = new File(["bytes"], "evil.txt");
|
||||
Object.defineProperty(invalid, "webkitRelativePath", { value: "../evil.txt" });
|
||||
|
||||
let outcome: Awaited<ReturnType<typeof result.current.uploadFiles>> | undefined;
|
||||
await act(async () => {
|
||||
outcome = await result.current.uploadFiles("", [file(A_TXT), invalid]);
|
||||
});
|
||||
|
||||
expect(outcome?.skipped).toEqual(["../evil.txt"]);
|
||||
expect(outcome?.failed).toBe(1);
|
||||
expect(uploadWorkspaceFile).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.uploads.find((item) => item.relativePath === "../evil.txt")?.status).toBe(
|
||||
"failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not overlap a second batch with an active upload", async () => {
|
||||
let releaseFirst!: () => void;
|
||||
uploadWorkspaceFile.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFirst = () => resolve({ path: A_TXT, size_bytes: 5 });
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
let first!: Promise<Awaited<ReturnType<typeof result.current.uploadFiles>>>;
|
||||
await act(async () => {
|
||||
first = result.current.uploadFiles("", [file(A_TXT)]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const second = await result.current.uploadFiles("", [file(B_TXT)]);
|
||||
expect(second.failed).toBe(1);
|
||||
expect(uploadWorkspaceFile).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
releaseFirst();
|
||||
await first;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileUpload conflict flow", () => {
|
||||
it("uploads nothing until the conflicts are resolved", async () => {
|
||||
preflightWorkspaceUpload.mockResolvedValue([{ path: `${FIXTURES}/${A_TXT}`, is_dir: false }]);
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
act(() => {
|
||||
void result.current.uploadFiles(FIXTURES, [file(A_TXT), file(B_TXT)]);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.conflicts).not.toBeNull());
|
||||
expect(result.current.conflicts?.conflicts).toEqual([
|
||||
{ path: `${FIXTURES}/${A_TXT}`, is_dir: false },
|
||||
]);
|
||||
// The whole batch is parked, including the file that did not conflict.
|
||||
expect(uploadWorkspaceFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancelling writes nothing at all, including unconflicted files", async () => {
|
||||
preflightWorkspaceUpload.mockResolvedValue([{ path: `${FIXTURES}/${A_TXT}`, is_dir: false }]);
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
let outcome: Awaited<ReturnType<typeof result.current.uploadFiles>> | undefined;
|
||||
act(() => {
|
||||
void result.current
|
||||
.uploadFiles(FIXTURES, [file(A_TXT), file(B_TXT)])
|
||||
.then((r) => (outcome = r));
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflicts).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
result.current.cancelConflicts();
|
||||
});
|
||||
|
||||
expect(uploadWorkspaceFile).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(outcome?.cancelled).toBe(true));
|
||||
expect(result.current.uploads).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies a per-file resolution and omits skipped files", async () => {
|
||||
preflightWorkspaceUpload.mockResolvedValue([
|
||||
{ path: `${FIXTURES}/${A_TXT}`, is_dir: false },
|
||||
{ path: `${FIXTURES}/${B_TXT}`, is_dir: false },
|
||||
]);
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
act(() => {
|
||||
void result.current.uploadFiles(FIXTURES, [file(A_TXT), file(B_TXT), file(C_TXT)]);
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflicts).not.toBeNull());
|
||||
|
||||
const choices = new Map<string, ConflictChoice>([
|
||||
[`${FIXTURES}/${A_TXT}`, "replace"],
|
||||
[`${FIXTURES}/${B_TXT}`, "skip"],
|
||||
]);
|
||||
await act(async () => {
|
||||
await result.current.resolveConflicts(choices);
|
||||
});
|
||||
|
||||
const uploadedPaths = uploadWorkspaceFile.mock.calls.map((c) => c[0].relativePath);
|
||||
expect(uploadedPaths).toEqual([A_TXT, C_TXT]);
|
||||
expect(uploadWorkspaceFile.mock.calls[0][0].resolution).toBe("replace");
|
||||
// The unconflicted file carries no resolution.
|
||||
expect(uploadWorkspaceFile.mock.calls[1][0].resolution).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileUpload failure isolation", () => {
|
||||
it("one failing file does not stop the others", async () => {
|
||||
uploadWorkspaceFile.mockImplementation(async ({ relativePath }: { relativePath: string }) => {
|
||||
if (relativePath === B_TXT) throw new Error("upload failed (413)");
|
||||
return { path: relativePath, size_bytes: 5 };
|
||||
});
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
let outcome: Awaited<ReturnType<typeof result.current.uploadFiles>> | undefined;
|
||||
await act(async () => {
|
||||
outcome = await result.current.uploadFiles("", [file(A_TXT), file(B_TXT), file(C_TXT)]);
|
||||
});
|
||||
|
||||
expect(outcome?.uploaded.map((u) => u.path)).toEqual([A_TXT, C_TXT]);
|
||||
expect(outcome?.failed).toBe(1);
|
||||
const failedItem = result.current.uploads.find((u) => u.relativePath === B_TXT);
|
||||
expect(failedItem?.status).toBe("failed");
|
||||
expect(failedItem?.error).toContain("413");
|
||||
});
|
||||
|
||||
it("records the server-reported path after a rename", async () => {
|
||||
preflightWorkspaceUpload.mockResolvedValue([{ path: A_TXT, is_dir: false }]);
|
||||
uploadWorkspaceFile.mockResolvedValue({
|
||||
path: "a-1.txt",
|
||||
size_bytes: 5,
|
||||
resolution_applied: "keep_both",
|
||||
});
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
act(() => {
|
||||
void result.current.uploadFiles("", [file(A_TXT)]);
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflicts).not.toBeNull());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.resolveConflicts(new Map([[A_TXT, "keep_both"]]));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.uploads[0]?.writtenPath).toBe("a-1.txt"));
|
||||
});
|
||||
|
||||
it("marks every file failed when the preflight itself fails", async () => {
|
||||
preflightWorkspaceUpload.mockRejectedValue(new Error("workspace not ready"));
|
||||
const { result } = renderHook(() => useFileUpload("sess-1"));
|
||||
|
||||
let outcome: Awaited<ReturnType<typeof result.current.uploadFiles>> | undefined;
|
||||
await act(async () => {
|
||||
outcome = await result.current.uploadFiles("", [file(A_TXT), file(B_TXT)]);
|
||||
});
|
||||
|
||||
expect(uploadWorkspaceFile).not.toHaveBeenCalled();
|
||||
expect(outcome?.failed).toBe(2);
|
||||
expect(result.current.uploads.every((u) => u.status === "failed")).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels a parked batch when the session changes", async () => {
|
||||
preflightWorkspaceUpload.mockResolvedValue([{ path: `${FIXTURES}/${A_TXT}`, is_dir: false }]);
|
||||
const { result, rerender } = renderHook(({ sessionId }) => useFileUpload(sessionId), {
|
||||
initialProps: { sessionId: "sess-1" },
|
||||
});
|
||||
|
||||
let outcome: Awaited<ReturnType<typeof result.current.uploadFiles>> | undefined;
|
||||
act(() => {
|
||||
void result.current.uploadFiles(FIXTURES, [file(A_TXT)]).then((value) => (outcome = value));
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflicts).not.toBeNull());
|
||||
|
||||
rerender({ sessionId: "sess-2" });
|
||||
|
||||
await waitFor(() => expect(outcome?.cancelled).toBe(true));
|
||||
expect(result.current.conflicts).toBeNull();
|
||||
expect(uploadWorkspaceFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops an active batch when the session changes", async () => {
|
||||
let releaseFirst!: () => void;
|
||||
uploadWorkspaceFile.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFirst = () => resolve({ path: A_TXT, size_bytes: 5 });
|
||||
}),
|
||||
);
|
||||
const { result, rerender } = renderHook(({ sessionId }) => useFileUpload(sessionId), {
|
||||
initialProps: { sessionId: "sess-1" },
|
||||
});
|
||||
|
||||
let outcome!: Promise<Awaited<ReturnType<typeof result.current.uploadFiles>>>;
|
||||
await act(async () => {
|
||||
outcome = result.current.uploadFiles("", [file(A_TXT), file(B_TXT)]);
|
||||
await waitFor(() => expect(uploadWorkspaceFile).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
rerender({ sessionId: "sess-2" });
|
||||
await act(async () => {
|
||||
releaseFirst();
|
||||
await outcome;
|
||||
});
|
||||
|
||||
await expect(outcome).resolves.toMatchObject({ cancelled: true });
|
||||
expect(uploadWorkspaceFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import {
|
||||
preflightWorkspaceUpload,
|
||||
uploadWorkspaceFile,
|
||||
type UploadConflict,
|
||||
type UploadResolution,
|
||||
type UploadedWorkspaceFile,
|
||||
} from "@/lib/api/domains/workspace-file-api";
|
||||
import {
|
||||
joinDestination,
|
||||
normalizeUploadSelection,
|
||||
type UploadSelectionEntry,
|
||||
} from "@/lib/utils/upload-selection";
|
||||
|
||||
/**
|
||||
* Per-file upload status. Mirrors the vocabulary chat attachments already use,
|
||||
* plus `blocked` for a file whose destination conflicts and is awaiting a
|
||||
* decision.
|
||||
*/
|
||||
export type UploadStatus = "pending" | "blocked" | "uploading" | "ready" | "failed";
|
||||
|
||||
export type UploadItem = {
|
||||
id: string;
|
||||
relativePath: string;
|
||||
destinationPath: string;
|
||||
status: UploadStatus;
|
||||
/** Server-reported path, authoritative after a keep-both rename. */
|
||||
writtenPath?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Skip is a resolution in the UI but never reaches the wire. */
|
||||
export type ConflictChoice = UploadResolution | "skip";
|
||||
|
||||
export type PendingConflicts = {
|
||||
conflicts: UploadConflict[];
|
||||
/** Destination path -> relative path, so the dialog can label per file. */
|
||||
byDestination: Map<string, string>;
|
||||
};
|
||||
|
||||
export type UploadFilesResult = {
|
||||
uploaded: UploadedWorkspaceFile[];
|
||||
cancelled: boolean;
|
||||
failed: number;
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
type PendingBatch = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
dir: string;
|
||||
repo?: string;
|
||||
entries: UploadSelectionEntry[];
|
||||
skipped: string[];
|
||||
resolve: (result: UploadFilesResult) => void;
|
||||
};
|
||||
|
||||
type ItemPatcher = (id: string, patch: Partial<UploadItem>) => void;
|
||||
|
||||
const EMPTY_RESULT: UploadFilesResult = { uploaded: [], cancelled: false, failed: 0, skipped: [] };
|
||||
const CANCELLED_RESULT: UploadFilesResult = {
|
||||
uploaded: [],
|
||||
cancelled: true,
|
||||
failed: 0,
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
function failedUploadResult(failed: number, skipped: string[]): UploadFilesResult {
|
||||
return { uploaded: [], cancelled: false, failed, skipped };
|
||||
}
|
||||
|
||||
function handleEmptyUploadSelection(
|
||||
dir: string,
|
||||
skipped: string[],
|
||||
batchId: number,
|
||||
setUploads: Dispatch<SetStateAction<UploadItem[]>>,
|
||||
): UploadFilesResult {
|
||||
if (skipped.length === 0) return EMPTY_RESULT;
|
||||
setUploads(buildUploadItems(batchId, dir, [], skipped));
|
||||
return failedUploadResult(skipped.length, skipped);
|
||||
}
|
||||
|
||||
function markBatchFailed(
|
||||
batchId: number,
|
||||
message: string | undefined,
|
||||
setUploads: Dispatch<SetStateAction<UploadItem[]>>,
|
||||
) {
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id.startsWith(`${batchId}:`) && item.status !== "failed"
|
||||
? { ...item, status: "failed", error: message }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function useSessionChangeReset(
|
||||
sessionId: string | null,
|
||||
pendingRef: MutableRefObject<PendingBatch | null>,
|
||||
activeBatchRef: MutableRefObject<PendingBatch | null>,
|
||||
setConflicts: Dispatch<SetStateAction<PendingConflicts | null>>,
|
||||
setUploads: Dispatch<SetStateAction<UploadItem[]>>,
|
||||
) {
|
||||
const previousSessionRef = useRef(sessionId);
|
||||
useEffect(() => {
|
||||
if (previousSessionRef.current === sessionId) return;
|
||||
previousSessionRef.current = sessionId;
|
||||
const pending = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
activeBatchRef.current = null;
|
||||
setConflicts(null);
|
||||
setUploads([]);
|
||||
if (pending) {
|
||||
pending.resolve({ ...CANCELLED_RESULT, skipped: pending.skipped });
|
||||
}
|
||||
}, [sessionId, pendingRef, activeBatchRef, setConflicts, setUploads]);
|
||||
}
|
||||
|
||||
function itemId(batchId: number, destinationPath: string, index: number): string {
|
||||
return `${batchId}:${index}:${destinationPath}`;
|
||||
}
|
||||
|
||||
function buildUploadItems(
|
||||
batchId: number,
|
||||
dir: string,
|
||||
entries: UploadSelectionEntry[],
|
||||
skipped: string[],
|
||||
): UploadItem[] {
|
||||
const entryItems = entries.map((entry, index) => {
|
||||
const destinationPath = joinDestination(dir, entry.relativePath);
|
||||
return {
|
||||
id: itemId(batchId, destinationPath, index),
|
||||
relativePath: entry.relativePath,
|
||||
destinationPath,
|
||||
status: "pending" as const,
|
||||
};
|
||||
});
|
||||
const skippedItems = skipped.map((relativePath, index) => ({
|
||||
id: `${batchId}:skipped:${index}:${relativePath}`,
|
||||
relativePath,
|
||||
destinationPath: joinDestination(dir, relativePath),
|
||||
status: "failed" as const,
|
||||
}));
|
||||
return [...entryItems, ...skippedItems];
|
||||
}
|
||||
|
||||
function destinationIndex(dir: string, entries: UploadSelectionEntry[]): Map<string, string> {
|
||||
const byDestination = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
byDestination.set(joinDestination(dir, entry.relativePath), entry.relativePath);
|
||||
}
|
||||
return byDestination;
|
||||
}
|
||||
|
||||
function preflightBatch(
|
||||
sessionId: string,
|
||||
dir: string,
|
||||
repo: string | undefined,
|
||||
entries: UploadSelectionEntry[],
|
||||
) {
|
||||
return preflightWorkspaceUpload({
|
||||
sessionId,
|
||||
dir,
|
||||
repo,
|
||||
paths: entries.map((entry) => entry.relativePath),
|
||||
});
|
||||
}
|
||||
|
||||
function parkUploadBatch({
|
||||
batch,
|
||||
found,
|
||||
dir,
|
||||
entries,
|
||||
pendingRef,
|
||||
setConflicts,
|
||||
setUploads,
|
||||
}: {
|
||||
batch: PendingBatch;
|
||||
found: UploadConflict[];
|
||||
dir: string;
|
||||
entries: UploadSelectionEntry[];
|
||||
pendingRef: MutableRefObject<PendingBatch | null>;
|
||||
setConflicts: Dispatch<SetStateAction<PendingConflicts | null>>;
|
||||
setUploads: Dispatch<SetStateAction<UploadItem[]>>;
|
||||
}): Promise<UploadFilesResult> {
|
||||
const conflicting = new Set(found.map((conflict) => conflict.path));
|
||||
setUploads((prev) =>
|
||||
prev.map((item) =>
|
||||
conflicting.has(item.destinationPath) ? { ...item, status: "blocked" } : item,
|
||||
),
|
||||
);
|
||||
return new Promise<UploadFilesResult>((resolve) => {
|
||||
pendingRef.current = { ...batch, resolve };
|
||||
setConflicts({ conflicts: found, byDestination: destinationIndex(dir, entries) });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload each surviving file in the batch, one request per file so a rejection
|
||||
* of one does not fail the rest.
|
||||
*/
|
||||
async function performUploads({
|
||||
sessionId,
|
||||
batch,
|
||||
choices,
|
||||
patch,
|
||||
drop,
|
||||
isActive,
|
||||
}: {
|
||||
sessionId: string;
|
||||
batch: PendingBatch;
|
||||
choices: Map<string, ConflictChoice>;
|
||||
patch: ItemPatcher;
|
||||
drop: (id: string) => void;
|
||||
isActive: () => boolean;
|
||||
}): Promise<UploadFilesResult> {
|
||||
const uploaded: UploadedWorkspaceFile[] = [];
|
||||
let failed = batch.skipped.length;
|
||||
|
||||
for (const [index, entry] of batch.entries.entries()) {
|
||||
if (!isActive()) {
|
||||
return { uploaded, cancelled: true, failed, skipped: batch.skipped };
|
||||
}
|
||||
const destinationPath = joinDestination(batch.dir, entry.relativePath);
|
||||
const id = itemId(batch.id, destinationPath, index);
|
||||
const choice = choices.get(destinationPath);
|
||||
|
||||
if (choice === "skip") {
|
||||
drop(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
patch(id, { status: "uploading" });
|
||||
try {
|
||||
const result = await uploadWorkspaceFile({
|
||||
sessionId,
|
||||
dir: batch.dir,
|
||||
repo: batch.repo,
|
||||
relativePath: entry.relativePath,
|
||||
file: entry.file,
|
||||
resolution: choice,
|
||||
});
|
||||
uploaded.push(result);
|
||||
patch(id, { status: "ready", writtenPath: result.path });
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
patch(id, {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { uploaded, cancelled: !isActive(), failed, skipped: batch.skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-phase workspace upload.
|
||||
*
|
||||
* Phase one preflights the whole selection and, if anything conflicts, parks the
|
||||
* batch and exposes `conflicts` for a dialog to resolve. Phase two uploads.
|
||||
* Nothing is written until every conflict has a decision, and cancelling writes
|
||||
* nothing at all, including the files that had no conflict. That guarantee is
|
||||
* why non-conflicting files are not uploaded eagerly while the dialog is open.
|
||||
*/
|
||||
export function useFileUpload(sessionId: string | null) {
|
||||
const [uploads, setUploads] = useState<UploadItem[]>([]);
|
||||
const [conflicts, setConflicts] = useState<PendingConflicts | null>(null);
|
||||
const pendingRef = useRef<PendingBatch | null>(null);
|
||||
const activeBatchRef = useRef<PendingBatch | null>(null);
|
||||
const nextBatchIdRef = useRef(0);
|
||||
useSessionChangeReset(sessionId, pendingRef, activeBatchRef, setConflicts, setUploads);
|
||||
|
||||
const patchItem = useCallback<ItemPatcher>((id, patch) => {
|
||||
setUploads((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
}, []);
|
||||
|
||||
const dropItem = useCallback((id: string) => {
|
||||
setUploads((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const runBatch = useCallback(
|
||||
(batch: PendingBatch, choices: Map<string, ConflictChoice>) =>
|
||||
performUploads({
|
||||
sessionId: batch.sessionId,
|
||||
batch,
|
||||
choices,
|
||||
patch: patchItem,
|
||||
drop: dropItem,
|
||||
isActive: () => activeBatchRef.current?.id === batch.id,
|
||||
}),
|
||||
[patchItem, dropItem],
|
||||
);
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (dir: string, files: ArrayLike<File>, repo?: string): Promise<UploadFilesResult> => {
|
||||
if (!sessionId) return EMPTY_RESULT;
|
||||
const { entries, skipped } = normalizeUploadSelection(files);
|
||||
if (activeBatchRef.current) {
|
||||
return failedUploadResult(entries.length + skipped.length, skipped);
|
||||
}
|
||||
if (entries.length === 0)
|
||||
return handleEmptyUploadSelection(dir, skipped, ++nextBatchIdRef.current, setUploads);
|
||||
|
||||
const batch: PendingBatch = {
|
||||
id: ++nextBatchIdRef.current,
|
||||
sessionId,
|
||||
dir,
|
||||
repo,
|
||||
entries,
|
||||
skipped,
|
||||
resolve: () => {},
|
||||
};
|
||||
activeBatchRef.current = batch;
|
||||
setUploads(buildUploadItems(batch.id, dir, entries, skipped));
|
||||
|
||||
let found: UploadConflict[];
|
||||
try {
|
||||
found = await preflightBatch(sessionId, dir, repo, entries);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
markBatchFailed(batch.id, message, setUploads);
|
||||
if (activeBatchRef.current?.id === batch.id) activeBatchRef.current = null;
|
||||
return failedUploadResult(entries.length + skipped.length, skipped);
|
||||
}
|
||||
|
||||
if (activeBatchRef.current?.id !== batch.id) {
|
||||
return { ...CANCELLED_RESULT, skipped: batch.skipped };
|
||||
}
|
||||
|
||||
if (found.length === 0) {
|
||||
const result = await runBatch(batch, new Map());
|
||||
if (activeBatchRef.current?.id === batch.id) activeBatchRef.current = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Park the batch and hand control to the dialog. Nothing has been written.
|
||||
return parkUploadBatch({
|
||||
batch,
|
||||
found,
|
||||
dir,
|
||||
entries,
|
||||
pendingRef,
|
||||
setConflicts,
|
||||
setUploads,
|
||||
});
|
||||
},
|
||||
[sessionId, runBatch],
|
||||
);
|
||||
|
||||
/** Apply the dialog's per-file decisions and upload what survives. */
|
||||
const resolveConflicts = useCallback(
|
||||
async (choices: Map<string, ConflictChoice>) => {
|
||||
const batch = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
setConflicts(null);
|
||||
if (!batch) return;
|
||||
const result = await runBatch(batch, choices);
|
||||
if (activeBatchRef.current?.id === batch.id) activeBatchRef.current = null;
|
||||
batch.resolve(result);
|
||||
},
|
||||
[runBatch],
|
||||
);
|
||||
|
||||
/** Cancel the parked batch. Nothing is uploaded, not even unconflicted files. */
|
||||
const cancelConflicts = useCallback(() => {
|
||||
const batch = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
setConflicts(null);
|
||||
setUploads([]);
|
||||
if (activeBatchRef.current?.id === batch?.id) activeBatchRef.current = null;
|
||||
batch?.resolve({ ...CANCELLED_RESULT, skipped: batch.skipped });
|
||||
}, []);
|
||||
|
||||
const clearUploads = useCallback(() => setUploads([]), []);
|
||||
|
||||
return { uploads, conflicts, uploadFiles, resolveConflicts, cancelConflicts, clearUploads };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "../client";
|
||||
import { preflightWorkspaceUpload, uploadWorkspaceFile } from "./workspace-file-api";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("preflightWorkspaceUpload", () => {
|
||||
it("posts the destination-relative path list and returns the conflicts", async () => {
|
||||
const fetchSpy = vi.fn(async () =>
|
||||
jsonResponse({ conflicts: [{ path: "fixtures/there.txt", is_dir: false }] }),
|
||||
);
|
||||
global.fetch = fetchSpy as typeof global.fetch;
|
||||
|
||||
const conflicts = await preflightWorkspaceUpload({
|
||||
sessionId: "sess-1",
|
||||
dir: "fixtures",
|
||||
paths: ["there.txt", "missing.txt"],
|
||||
});
|
||||
|
||||
expect(conflicts).toEqual([{ path: "fixtures/there.txt", is_dir: false }]);
|
||||
const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toContain("/api/v1/task-sessions/sess-1/workspace/files/preflight");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.credentials).toBe("include");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
dir: "fixtures",
|
||||
repo: "",
|
||||
paths: ["there.txt", "missing.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a missing conflicts array as no conflicts", async () => {
|
||||
global.fetch = vi.fn(async () => jsonResponse({})) as typeof global.fetch;
|
||||
|
||||
await expect(
|
||||
preflightWorkspaceUpload({ sessionId: "sess-1", dir: "", paths: ["a.txt"] }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("raises ApiError carrying the server message", async () => {
|
||||
global.fetch = vi.fn(async () =>
|
||||
jsonResponse({ error: "path traversal detected in relative_path" }, 400),
|
||||
) as typeof global.fetch;
|
||||
|
||||
await expect(
|
||||
preflightWorkspaceUpload({ sessionId: "sess-1", dir: "", paths: ["../x"] }),
|
||||
).rejects.toMatchObject({ status: 400, message: "path traversal detected in relative_path" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadWorkspaceFile", () => {
|
||||
it("posts multipart fields and lets the browser set the content type", async () => {
|
||||
const fetchSpy = vi.fn(async () =>
|
||||
jsonResponse({ path: "fixtures/a.txt", size_bytes: 5, resolution_applied: "" }, 201),
|
||||
);
|
||||
global.fetch = fetchSpy as typeof global.fetch;
|
||||
|
||||
const file = new File(["bytes"], "a.txt");
|
||||
const result = await uploadWorkspaceFile({
|
||||
sessionId: "sess-1",
|
||||
dir: "fixtures",
|
||||
relativePath: "a.txt",
|
||||
file,
|
||||
});
|
||||
|
||||
expect(result.path).toBe("fixtures/a.txt");
|
||||
const [url, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toContain("/api/v1/task-sessions/sess-1/workspace/files");
|
||||
expect(init.credentials).toBe("include");
|
||||
expect((init.headers as Record<string, string> | undefined)?.["Content-Type"]).toBeUndefined();
|
||||
|
||||
const form = init.body as FormData;
|
||||
expect(form.get("dir")).toBe("fixtures");
|
||||
expect(form.get("relative_path")).toBe("a.txt");
|
||||
expect(form.get("size_bytes")).toBe(String(file.size));
|
||||
expect(form.get("file")).toBeInstanceOf(File);
|
||||
// Absent when there is no conflict, so the server refuses a silent overwrite.
|
||||
expect(form.get("resolution")).toBeNull();
|
||||
});
|
||||
|
||||
it("carries the resolution when one was chosen", async () => {
|
||||
const fetchSpy = vi.fn(async () =>
|
||||
jsonResponse(
|
||||
{ path: "fixtures/a-1.txt", size_bytes: 5, resolution_applied: "keep_both" },
|
||||
201,
|
||||
),
|
||||
);
|
||||
global.fetch = fetchSpy as typeof global.fetch;
|
||||
|
||||
const result = await uploadWorkspaceFile({
|
||||
sessionId: "sess-1",
|
||||
dir: "fixtures",
|
||||
relativePath: "a.txt",
|
||||
file: new File(["bytes"], "a.txt"),
|
||||
resolution: "keep_both",
|
||||
});
|
||||
|
||||
const [, init] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect((init.body as FormData).get("resolution")).toBe("keep_both");
|
||||
// The server-reported path is authoritative after a rename.
|
||||
expect(result.path).toBe("fixtures/a-1.txt");
|
||||
});
|
||||
|
||||
it("preserves a 409 so the caller can prompt rather than fail", async () => {
|
||||
global.fetch = vi.fn(async () =>
|
||||
jsonResponse({ error: "upload destination already exists: a.txt" }, 409),
|
||||
) as typeof global.fetch;
|
||||
|
||||
const error = await uploadWorkspaceFile({
|
||||
sessionId: "sess-1",
|
||||
dir: "",
|
||||
relativePath: "a.txt",
|
||||
file: new File(["bytes"], "a.txt"),
|
||||
}).catch((e: unknown) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect((error as ApiError).status).toBe(409);
|
||||
});
|
||||
|
||||
it("falls back to a status diagnostic when the server sends no JSON", async () => {
|
||||
global.fetch = vi.fn(
|
||||
async () => new Response("upstream exploded", { status: 502 }),
|
||||
) as typeof global.fetch;
|
||||
|
||||
await expect(
|
||||
uploadWorkspaceFile({
|
||||
sessionId: "sess-1",
|
||||
dir: "",
|
||||
relativePath: "a.txt",
|
||||
file: new File(["bytes"], "a.txt"),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { readInterimSettingsInterlockToken } from "@/src/boot-payload";
|
||||
import { ApiError } from "../client";
|
||||
|
||||
/** What to do when an upload's destination already exists. */
|
||||
export type UploadResolution = "replace" | "keep_both";
|
||||
|
||||
export type UploadConflict = {
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
};
|
||||
|
||||
export type UploadedWorkspaceFile = {
|
||||
path: string;
|
||||
size_bytes: number;
|
||||
resolution_applied?: string;
|
||||
};
|
||||
|
||||
export type UploadWorkspaceFileParams = {
|
||||
sessionId: string;
|
||||
dir: string;
|
||||
relativePath: string;
|
||||
file: File;
|
||||
repo?: string;
|
||||
resolution?: UploadResolution;
|
||||
};
|
||||
|
||||
function interlockHeaders(): Record<string, string> | undefined {
|
||||
const token = readInterimSettingsInterlockToken();
|
||||
return token ? { ["X-Kandev-Interim-Settings-Interlock"]: token } : undefined;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response, fallback: string): Promise<[string, unknown]> {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Keep the status as the useful error when the server returned no JSON.
|
||||
}
|
||||
const message =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: fallback;
|
||||
return [message, body];
|
||||
}
|
||||
|
||||
/**
|
||||
* Report which of the destination-relative paths already exist, before any
|
||||
* content is uploaded. This is what lets the caller resolve every conflict up
|
||||
* front rather than discovering them one failed upload at a time.
|
||||
*/
|
||||
export async function preflightWorkspaceUpload(params: {
|
||||
sessionId: string;
|
||||
dir: string;
|
||||
paths: string[];
|
||||
repo?: string;
|
||||
}): Promise<UploadConflict[]> {
|
||||
const response = await fetch(
|
||||
`${getBackendConfig().apiBaseUrl}/api/v1/task-sessions/${params.sessionId}/workspace/files/preflight`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(interlockHeaders() ?? {}) },
|
||||
body: JSON.stringify({ dir: params.dir, repo: params.repo ?? "", paths: params.paths }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// i18n-exempt: server-provided error text, or an HTTP status diagnostic when
|
||||
// the server sent none. Callers render translated copy for the generic case.
|
||||
const [message, body] = await readErrorMessage(
|
||||
response,
|
||||
`Upload preflight failed (${response.status})`,
|
||||
);
|
||||
throw new ApiError(message, response.status, body);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { conflicts?: UploadConflict[] };
|
||||
return payload.conflicts ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one file. One request carries one file so a rejection of one file in a
|
||||
* selection does not fail the rest, and so per-file status stays observable.
|
||||
*/
|
||||
export async function uploadWorkspaceFile(
|
||||
params: UploadWorkspaceFileParams,
|
||||
): Promise<UploadedWorkspaceFile> {
|
||||
const form = new FormData();
|
||||
form.append("dir", params.dir);
|
||||
form.append("repo", params.repo ?? "");
|
||||
form.append("relative_path", params.relativePath);
|
||||
form.append("size_bytes", String(params.file.size));
|
||||
if (params.resolution) form.append("resolution", params.resolution);
|
||||
form.append("file", params.file, params.file.name);
|
||||
|
||||
const response = await fetch(
|
||||
`${getBackendConfig().apiBaseUrl}/api/v1/task-sessions/${params.sessionId}/workspace/files`,
|
||||
{ method: "POST", credentials: "include", body: form, headers: interlockHeaders() },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// i18n-exempt: server-provided error text, or an HTTP status diagnostic when
|
||||
// the server sent none. Callers render translated copy for the generic case.
|
||||
const [message, body] = await readErrorMessage(response, `Upload failed (${response.status})`);
|
||||
throw new ApiError(message, response.status, body);
|
||||
}
|
||||
|
||||
return (await response.json()) as UploadedWorkspaceFile;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { joinDestination, normalizeUploadSelection } from "./upload-selection";
|
||||
|
||||
/** Build a File carrying an optional webkitRelativePath, as a folder pick does. */
|
||||
function makeFile(name: string, relativePath?: string): File {
|
||||
const file = new File(["x"], name);
|
||||
if (relativePath !== undefined) {
|
||||
Object.defineProperty(file, "webkitRelativePath", { value: relativePath });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("normalizeUploadSelection", () => {
|
||||
it("uses bare names for a flat multi-file pick", () => {
|
||||
const { entries, skipped } = normalizeUploadSelection([makeFile("a.txt"), makeFile("b.json")]);
|
||||
|
||||
expect(entries.map((e) => e.relativePath)).toEqual(["a.txt", "b.json"]);
|
||||
expect(skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves directory structure from a folder pick", () => {
|
||||
const { entries } = normalizeUploadSelection([
|
||||
makeFile("root.txt", "fixtures/root.txt"),
|
||||
makeFile("leaf.txt", "fixtures/deep/nested/leaf.txt"),
|
||||
]);
|
||||
|
||||
expect(entries.map((e) => e.relativePath)).toEqual([
|
||||
"fixtures/root.txt",
|
||||
"fixtures/deep/nested/leaf.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces the same shape whichever picker was used", () => {
|
||||
const flat = normalizeUploadSelection([makeFile("a.txt")]);
|
||||
const folder = normalizeUploadSelection([makeFile("a.txt", "a.txt")]);
|
||||
|
||||
expect(flat.entries[0].relativePath).toBe(folder.entries[0].relativePath);
|
||||
});
|
||||
|
||||
it("skips a traversing path instead of failing the batch", () => {
|
||||
const { entries, skipped } = normalizeUploadSelection([
|
||||
makeFile("good.txt"),
|
||||
makeFile("evil.txt", "../evil.txt"),
|
||||
makeFile("also-good.txt"),
|
||||
]);
|
||||
|
||||
expect(entries.map((e) => e.relativePath)).toEqual(["good.txt", "also-good.txt"]);
|
||||
expect(skipped).toEqual(["../evil.txt"]);
|
||||
});
|
||||
|
||||
it("skips an absolute path", () => {
|
||||
const { entries, skipped } = normalizeUploadSelection([makeFile("p", "/etc/passwd")]);
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
expect(skipped).toEqual(["/etc/passwd"]);
|
||||
});
|
||||
|
||||
it("normalizes backslashes and redundant segments", () => {
|
||||
const { entries } = normalizeUploadSelection([makeFile("x.txt", "dir\\.\\sub//x.txt")]);
|
||||
|
||||
expect(entries[0].relativePath).toBe("dir/sub/x.txt");
|
||||
});
|
||||
|
||||
it("skips an interior traversal segment", () => {
|
||||
const { entries, skipped } = normalizeUploadSelection([makeFile("x.txt", "dir/../../x.txt")]);
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
expect(skipped).toEqual(["dir/../../x.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinDestination", () => {
|
||||
it("joins a folder and a relative path", () => {
|
||||
expect(joinDestination("fixtures", "a.txt")).toBe("fixtures/a.txt");
|
||||
expect(joinDestination("fixtures", "deep/a.txt")).toBe("fixtures/deep/a.txt");
|
||||
});
|
||||
|
||||
it("treats an empty folder as the workspace root", () => {
|
||||
expect(joinDestination("", "a.txt")).toBe("a.txt");
|
||||
});
|
||||
|
||||
it("tolerates surrounding slashes on the folder", () => {
|
||||
expect(joinDestination("/fixtures/", "a.txt")).toBe("fixtures/a.txt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Normalizes a browser file selection into destination-relative paths.
|
||||
*
|
||||
* A flat multi-file pick yields bare names. A folder pick (an input carrying
|
||||
* `webkitdirectory`) yields `webkitRelativePath`, which already includes the
|
||||
* chosen folder as its first segment. Both collapse to the same shape so the
|
||||
* upload hook, the conflict dialog, and the API client never branch on how the
|
||||
* files were chosen.
|
||||
*/
|
||||
export type UploadSelectionEntry = {
|
||||
file: File;
|
||||
relativePath: string;
|
||||
};
|
||||
|
||||
export type UploadSelection = {
|
||||
entries: UploadSelectionEntry[];
|
||||
/** Files that could not be read, reported so one bad entry is not silent. */
|
||||
skipped: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip a leading slash, collapse `.` segments, and reject anything that tries
|
||||
* to climb out of the destination. The backend rejects these too; refusing here
|
||||
* keeps a malformed pick from costing a round trip.
|
||||
*/
|
||||
function sanitizeRelativePath(raw: string): string | null {
|
||||
const normalized = raw.replace(/\\/g, "/");
|
||||
if (!normalized || normalized.startsWith("/")) return null;
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const segment of normalized.split("/")) {
|
||||
if (segment === "" || segment === ".") continue;
|
||||
if (segment === "..") return null;
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.length > 0 ? segments.join("/") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `FileList` (or array) from either picker into upload entries.
|
||||
*
|
||||
* `webkitRelativePath` is empty for a flat pick and populated for a directory
|
||||
* pick, so one code path serves both.
|
||||
*/
|
||||
export function normalizeUploadSelection(files: ArrayLike<File>): UploadSelection {
|
||||
const entries: UploadSelectionEntry[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
|
||||
const candidate = file.webkitRelativePath ? file.webkitRelativePath : file.name;
|
||||
const relativePath = sanitizeRelativePath(candidate);
|
||||
if (!relativePath) {
|
||||
skipped.push(candidate || file.name);
|
||||
continue;
|
||||
}
|
||||
entries.push({ file, relativePath });
|
||||
}
|
||||
|
||||
return { entries, skipped };
|
||||
}
|
||||
|
||||
/** Join a destination folder and a relative path into a workspace path. */
|
||||
export function joinDestination(dir: string, relativePath: string): string {
|
||||
const cleanDir = dir.replace(/^\/+|\/+$/g, "");
|
||||
return cleanDir ? `${cleanDir}/${relativePath}` : relativePath;
|
||||
}
|
||||
@@ -87,5 +87,6 @@
|
||||
"toggleCode": "Toggle code",
|
||||
"toggleWordWrap": "Toggle word wrap",
|
||||
"underlineCmdU": "Underline (Cmd+U)",
|
||||
"failedToOpenEditor": "Failed to open editor"
|
||||
"failedToOpenEditor": "Failed to open editor",
|
||||
"downloadFile": "Download file"
|
||||
}
|
||||
|
||||
@@ -1931,5 +1931,22 @@
|
||||
"branchPolicyOptionSummary": "from {{base}} to {{target}}",
|
||||
"branchPolicySelected": "{{name}} (from {{base}})",
|
||||
"branchPolicyUnavailable": "Base branch unavailable. Refresh branches or edit this policy.",
|
||||
"branchPolicyRequiresSingleRepository": "Branch policies require a single repository with the local executor."
|
||||
"branchPolicyRequiresSingleRepository": "Branch policies require a single repository with the local executor.",
|
||||
"uploadConflictTitle_one": "{{count}} file already exists",
|
||||
"uploadConflictTitle_other": "{{count}} files already exist",
|
||||
"uploadConflictBody": "Choose what to do with each one. Nothing is uploaded until you confirm.",
|
||||
"uploadConflictApplyToAll": "Apply to all",
|
||||
"uploadConflictKeepBoth": "Keep both",
|
||||
"uploadConflictReplace": "Replace",
|
||||
"uploadConflictSkip": "Skip",
|
||||
"uploadConflictConfirm": "Upload",
|
||||
"uploadFiles": "Upload files",
|
||||
"uploadFolder": "Upload folder",
|
||||
"uploadFilesHere": "Upload files here",
|
||||
"uploadComplete_one": "Uploaded {{count}} file",
|
||||
"uploadComplete_other": "Uploaded {{count}} files",
|
||||
"uploadedTo": "Uploaded to {{path}}",
|
||||
"failedToUploadFile": "Failed to upload file",
|
||||
"uploadPartiallyFailed_one": "{{count}} file failed to upload",
|
||||
"uploadPartiallyFailed_other": "{{count}} files failed to upload"
|
||||
}
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"toggleCode": "Ţōĝĝĺē ćōďē",
|
||||
"toggleWordWrap": "Ţōĝĝĺē ŵōŕď ŵŕàƥ",
|
||||
"underlineCmdU": "Ũńďēŕĺĩńē (Ćḿď+Ũ)",
|
||||
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ"
|
||||
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ",
|
||||
"downloadFile": "Ďōŵńĺōàď ƒĩĺē"
|
||||
}
|
||||
|
||||
@@ -1931,5 +1931,22 @@
|
||||
"branchPolicyOptionSummary": "ƒŕōḿ {{base}} ţō {{target}}",
|
||||
"branchPolicySelected": "{{name}} (ƒŕōḿ {{base}})",
|
||||
"branchPolicyUnavailable": "Ɓàśē ƀŕàńćĥ ũńàvàĩĺàƀĺē. Ŕēƒŕēśĥ ƀŕàńćĥēś ōŕ ēďĩţ ţĥĩś ƥōĺĩćŷ.",
|
||||
"branchPolicyRequiresSingleRepository": "Ɓŕàńćĥ ƥōĺĩćĩēś ŕēqũĩŕē à śĩńĝĺē ŕēƥōśĩţōŕŷ ŵĩţĥ ţĥē ĺōćàĺ ēxēćũţōŕ."
|
||||
"branchPolicyRequiresSingleRepository": "Ɓŕàńćĥ ƥōĺĩćĩēś ŕēqũĩŕē à śĩńĝĺē ŕēƥōśĩţōŕŷ ŵĩţĥ ţĥē ĺōćàĺ ēxēćũţōŕ.",
|
||||
"uploadConflictTitle_one": "{{count}} ƒĩĺē àĺŕēàďŷ ēxĩśţś",
|
||||
"uploadConflictTitle_other": "{{count}} ƒĩĺēś àĺŕēàďŷ ēxĩśţ",
|
||||
"uploadConflictBody": "Ćĥōōśē ŵĥàţ ţō ďō ŵĩţĥ ēàćĥ ōńē. Ńōţĥĩńĝ ĩś ũƥĺōàďēď ũńţĩĺ ŷōũ ćōńƒĩŕḿ.",
|
||||
"uploadConflictApplyToAll": "Àƥƥĺŷ ţō àĺĺ",
|
||||
"uploadConflictKeepBoth": "Ķēēƥ ƀōţĥ",
|
||||
"uploadConflictReplace": "Ŕēƥĺàćē",
|
||||
"uploadConflictSkip": "Śķĩƥ",
|
||||
"uploadConflictConfirm": "Ũƥĺōàď",
|
||||
"uploadFiles": "Ũƥĺōàď ƒĩĺēś",
|
||||
"uploadFolder": "Ũƥĺōàď ƒōĺďēŕ",
|
||||
"uploadFilesHere": "Ũƥĺōàď ƒĩĺēś ĥēŕē",
|
||||
"uploadComplete_one": "Ũƥĺōàďēď {{count}} ƒĩĺē",
|
||||
"uploadComplete_other": "Ũƥĺōàďēď {{count}} ƒĩĺēś",
|
||||
"uploadedTo": "Ũƥĺōàďēď ţō {{path}}",
|
||||
"failedToUploadFile": "Ƒàĩĺēď ţō ũƥĺōàď ƒĩĺē",
|
||||
"uploadPartiallyFailed_one": "{{count}} ƒĩĺē ƒàĩĺēď ţō ũƥĺōàď",
|
||||
"uploadPartiallyFailed_other": "{{count}} ƒĩĺēś ƒàĩĺēď ţō ũƥĺōàď"
|
||||
}
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"toggleCode": "Alternar código",
|
||||
"toggleWordWrap": "Alternar a quebra de linha",
|
||||
"underlineCmdU": "Sublinhado (Cmd+U)",
|
||||
"failedToOpenEditor": "Falha ao abrir o editor"
|
||||
"failedToOpenEditor": "Falha ao abrir o editor",
|
||||
"downloadFile": "Transferir ficheiro"
|
||||
}
|
||||
|
||||
@@ -1931,5 +1931,22 @@
|
||||
"branchPolicyOptionSummary": "de {{base}} para {{target}}",
|
||||
"branchPolicySelected": "{{name}} (de {{base}})",
|
||||
"branchPolicyUnavailable": "O branch base não está disponível. Atualize os branches ou edite esta política.",
|
||||
"branchPolicyRequiresSingleRepository": "As políticas de branches requerem um único repositório com o executor local."
|
||||
"branchPolicyRequiresSingleRepository": "As políticas de branches requerem um único repositório com o executor local.",
|
||||
"uploadConflictTitle_one": "{{count}} ficheiro já existe",
|
||||
"uploadConflictTitle_other": "{{count}} ficheiros já existem",
|
||||
"uploadConflictBody": "Escolha o que fazer com cada um. Nada é carregado até confirmar.",
|
||||
"uploadConflictApplyToAll": "Aplicar a todos",
|
||||
"uploadConflictKeepBoth": "Manter ambos",
|
||||
"uploadConflictReplace": "Substituir",
|
||||
"uploadConflictSkip": "Ignorar",
|
||||
"uploadConflictConfirm": "Carregar",
|
||||
"uploadFiles": "Carregar ficheiros",
|
||||
"uploadFolder": "Carregar pasta",
|
||||
"uploadFilesHere": "Carregar ficheiros aqui",
|
||||
"uploadComplete_one": "{{count}} ficheiro carregado",
|
||||
"uploadComplete_other": "{{count}} ficheiros carregados",
|
||||
"uploadedTo": "Carregado para {{path}}",
|
||||
"failedToUploadFile": "Falha ao carregar o ficheiro",
|
||||
"uploadPartiallyFailed_one": "Falha ao carregar {{count}} ficheiro",
|
||||
"uploadPartiallyFailed_other": "Falha ao carregar {{count}} ficheiros"
|
||||
}
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"toggleCode": "切换代码",
|
||||
"toggleWordWrap": "切换自动换行",
|
||||
"underlineCmdU": "下划线(Cmd+U)",
|
||||
"failedToOpenEditor": "打开编辑器失败"
|
||||
"failedToOpenEditor": "打开编辑器失败",
|
||||
"downloadFile": "下载文件"
|
||||
}
|
||||
|
||||
@@ -1931,5 +1931,22 @@
|
||||
"branchPolicyOptionSummary": "从 {{base}} 到 {{target}}",
|
||||
"branchPolicySelected": "{{name}}(从 {{base}})",
|
||||
"branchPolicyUnavailable": "基础分支不可用。请刷新分支或编辑此策略。",
|
||||
"branchPolicyRequiresSingleRepository": "使用本地执行器时,分支策略需要单个仓库。"
|
||||
"branchPolicyRequiresSingleRepository": "使用本地执行器时,分支策略需要单个仓库。",
|
||||
"uploadConflictTitle_one": "{{count}} 个文件已存在",
|
||||
"uploadConflictTitle_other": "{{count}} 个文件已存在",
|
||||
"uploadConflictBody": "请选择如何处理每个文件。确认之前不会上传任何内容。",
|
||||
"uploadConflictApplyToAll": "应用于全部",
|
||||
"uploadConflictKeepBoth": "两者都保留",
|
||||
"uploadConflictReplace": "替换",
|
||||
"uploadConflictSkip": "跳过",
|
||||
"uploadConflictConfirm": "上传",
|
||||
"uploadFiles": "上传文件",
|
||||
"uploadFolder": "上传文件夹",
|
||||
"uploadFilesHere": "上传文件到此处",
|
||||
"uploadComplete_one": "已上传 {{count}} 个文件",
|
||||
"uploadComplete_other": "已上传 {{count}} 个文件",
|
||||
"uploadedTo": "已上传至 {{path}}",
|
||||
"failedToUploadFile": "上传文件失败",
|
||||
"uploadPartiallyFailed_one": "{{count}} 个文件上传失败",
|
||||
"uploadPartiallyFailed_other": "{{count}} 个文件上传失败"
|
||||
}
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"toggleCode": "切換程式碼",
|
||||
"toggleWordWrap": "切換自動換行",
|
||||
"underlineCmdU": "下劃線(Cmd+U)",
|
||||
"failedToOpenEditor": "開啟編輯器失敗"
|
||||
"failedToOpenEditor": "開啟編輯器失敗",
|
||||
"downloadFile": "下載檔案"
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"addReviewer": "+ 新增審閱人",
|
||||
"addShort": "+ 新增",
|
||||
"addSubIssue": "新增子議題",
|
||||
"addToWorkspace": "新增到工作區",
|
||||
"addToWorkspace": "加入工作區",
|
||||
"addYourCommentOrInstruction": "新增你的評論或指示…",
|
||||
"addedRemoved": "新增 {{added}} · 刪除 {{removed}}",
|
||||
"adding": "正在新增…",
|
||||
@@ -1931,5 +1931,22 @@
|
||||
"branchPolicyOptionSummary": "從 {{base}} 到 {{target}}",
|
||||
"branchPolicySelected": "{{name}}(從 {{base}})",
|
||||
"branchPolicyUnavailable": "基礎分支不可用。請刷新分支或編輯此策略。",
|
||||
"branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。"
|
||||
"branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。",
|
||||
"uploadConflictTitle_one": "{{count}} 個檔案已存在",
|
||||
"uploadConflictTitle_other": "{{count}} 個檔案已存在",
|
||||
"uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上載任何內容。",
|
||||
"uploadConflictApplyToAll": "套用至全部",
|
||||
"uploadConflictKeepBoth": "兩者都保留",
|
||||
"uploadConflictReplace": "取代",
|
||||
"uploadConflictSkip": "略過",
|
||||
"uploadConflictConfirm": "上載",
|
||||
"uploadFiles": "上載檔案",
|
||||
"uploadFolder": "上載資料夾",
|
||||
"uploadFilesHere": "上載檔案至此",
|
||||
"uploadComplete_one": "已上載 {{count}} 個檔案",
|
||||
"uploadComplete_other": "已上載 {{count}} 個檔案",
|
||||
"uploadedTo": "已上載至 {{path}}",
|
||||
"failedToUploadFile": "上載檔案失敗",
|
||||
"uploadPartiallyFailed_one": "{{count}} 個檔案上載失敗",
|
||||
"uploadPartiallyFailed_other": "{{count}} 個檔案上載失敗"
|
||||
}
|
||||
|
||||
@@ -87,5 +87,6 @@
|
||||
"toggleCode": "切換程式碼",
|
||||
"toggleWordWrap": "切換自動換行",
|
||||
"underlineCmdU": "下劃線(Cmd+U)",
|
||||
"failedToOpenEditor": "開啟編輯器失敗"
|
||||
"failedToOpenEditor": "開啟編輯器失敗",
|
||||
"downloadFile": "下載檔案"
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"addReviewer": "+ 新增審閱人",
|
||||
"addShort": "+ 新增",
|
||||
"addSubIssue": "新增子議題",
|
||||
"addToWorkspace": "新增到工作區",
|
||||
"addToWorkspace": "加入工作區",
|
||||
"addYourCommentOrInstruction": "新增你的評論或指示…",
|
||||
"addedRemoved": "新增 {{added}} · 刪除 {{removed}}",
|
||||
"adding": "正在新增…",
|
||||
@@ -1931,5 +1931,22 @@
|
||||
"branchPolicyOptionSummary": "從 {{base}} 到 {{target}}",
|
||||
"branchPolicySelected": "{{name}}(從 {{base}})",
|
||||
"branchPolicyUnavailable": "基礎分支不可用。請重新整理分支或編輯此策略。",
|
||||
"branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。"
|
||||
"branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。",
|
||||
"uploadConflictTitle_one": "{{count}} 個檔案已存在",
|
||||
"uploadConflictTitle_other": "{{count}} 個檔案已存在",
|
||||
"uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上傳任何內容。",
|
||||
"uploadConflictApplyToAll": "套用至全部",
|
||||
"uploadConflictKeepBoth": "兩者都保留",
|
||||
"uploadConflictReplace": "取代",
|
||||
"uploadConflictSkip": "略過",
|
||||
"uploadConflictConfirm": "上傳",
|
||||
"uploadFiles": "上傳檔案",
|
||||
"uploadFolder": "上傳資料夾",
|
||||
"uploadFilesHere": "上傳檔案至此",
|
||||
"uploadComplete_one": "已上傳 {{count}} 個檔案",
|
||||
"uploadComplete_other": "已上傳 {{count}} 個檔案",
|
||||
"uploadedTo": "已上傳至 {{path}}",
|
||||
"failedToUploadFile": "上傳檔案失敗",
|
||||
"uploadPartiallyFailed_one": "{{count}} 個檔案上傳失敗",
|
||||
"uploadPartiallyFailed_other": "{{count}} 個檔案上傳失敗"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
---
|
||||
spec: docs/specs/ui/requirements/workspace-file-transfer.md
|
||||
created: 2026-09-01
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Implementation Plan: Workspace File Transfer
|
||||
|
||||
## Overview
|
||||
|
||||
Give the Files panel a working upload path and put download where people actually look for it.
|
||||
|
||||
The work splits cleanly along a dependency line. Download is pure frontend: the viewer already holds
|
||||
the file content, so adding the buttons touches no transport and can land first as a standalone
|
||||
change. Upload needs a new streamed write path built bottom up, because no workspace contract accepts
|
||||
raw bytes today.
|
||||
|
||||
Order: the agentctl write primitive and its conflict preflight, then the backend routes over them,
|
||||
then the web client and hook, then the resolution dialog, then the panel entry points. Download runs
|
||||
alongside from the start. End-to-end coverage lands once both halves are in.
|
||||
|
||||
Drag and drop from the desktop was announced in review and is deliberately **not** in this package.
|
||||
The file tree already owns HTML5 drag and drop for internal moves, there are other known side-panel
|
||||
issues outstanding, and adding an external-drop branch on top of both is a regression risk that buys
|
||||
nothing the menu and context-menu routes do not already deliver.
|
||||
|
||||
Verified against `v0.92.2-68-gc51ec0a21`.
|
||||
|
||||
## Decisions and where they came from
|
||||
|
||||
| Question | Decision | Source |
|
||||
| --- | --- | --- |
|
||||
| Scope | Upload and download both, in full | Requested; review confirmed both are wanted |
|
||||
| Size cap | `models.MaxMessageAttachmentBytes` | Consistency with chat attachments |
|
||||
| Name collision | Preflight the selection, then prompt per file with Replace / Keep both / Skip | Review: report conflicts and ask before uploading anything |
|
||||
| Folder upload | In scope, via the picker | Review: do it if it is not hard; both respondents wanted it |
|
||||
| Drag and drop from the OS | Out of scope | Deferred: other side-panel issues make touching the tree's DnD risky |
|
||||
| Rollout toggle | None | The workspace is already agent-writable; see the system design |
|
||||
|
||||
The collision and folder decisions reverse the earlier answers of auto-rename and flat-files-only.
|
||||
Both changed on review feedback. Auto-rename survives as the **Keep both** resolution rather than as
|
||||
the silent default.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Write primitive and preflight
|
||||
|
||||
- Add `WriteFileStream` to `apps/backend/internal/agentctl/server/process/workspace_files.go`, taking
|
||||
the destination, a resolution, and an `io.Reader`.
|
||||
- Resolve through the existing `resolveMutationPath` so containment, the `os.Root` handle, and
|
||||
symlink rules match `CreateFile` and `DeleteFile`. Do not join paths in the handler. **Every
|
||||
segment of a folder upload's relative path goes through the same rooted resolution.**
|
||||
- Create missing intermediate directories through the same rooted handle.
|
||||
- Take `runWorkspaceMutationBarrier` as the other mutators do.
|
||||
- Apply the resolution: `replace` overwrites, `keep_both` selects the next free `name-<n>.ext` reusing
|
||||
the search `installMaterializedAttachment` already implements, and an absent resolution against an
|
||||
existing destination is an error, never a silent overwrite.
|
||||
- Write to a temporary file in the destination directory, fsync, then rename into place. Remove the
|
||||
temporary file on every error path.
|
||||
- Emit the standard change notification through `mutationNotificationPath`.
|
||||
- Add `CheckUploadConflicts`, resolving a candidate path list through the same containment rules and
|
||||
reporting which already exist. Containment failures are reported as errors, not conflicts.
|
||||
|
||||
### agentctl routes
|
||||
|
||||
- New `apps/backend/internal/agentctl/server/api/workspace_upload.go`, registered beside the existing
|
||||
workspace file routes in `server.go`:
|
||||
- `POST /api/v1/workspace/file/upload-preflight`, JSON in and out.
|
||||
- `POST /api/v1/workspace/file/upload`, multipart, structured like `handleMaterializeAttachment`:
|
||||
bound the body with `http.MaxBytesReader`, read metadata with `multipart.Reader`, validate the
|
||||
declared size, and stream the `file` part into `WriteFileStream`.
|
||||
- Status mapping: `413` oversize, `409` unresolved existing destination, `400` containment rejection
|
||||
or size mismatch, `500` IO.
|
||||
|
||||
### Backend client and HTTP routes
|
||||
|
||||
- New `apps/backend/internal/agent/runtime/agentctl/client_workspace_upload.go` with
|
||||
`UploadWorkspaceFile` and `PreflightWorkspaceUpload`. The upload method copies the structure of
|
||||
`MaterializeAttachment` in `client_attachments.go`: `io.Pipe`, a goroutine writing the multipart
|
||||
body, the long-running HTTP client, a bounded error-body read. No base64 anywhere.
|
||||
- New `apps/backend/internal/task/handlers/workspace_file_http_handlers.go` exposing
|
||||
`POST /api/v1/task-sessions/:id/workspace/files` and
|
||||
`POST /api/v1/task-sessions/:id/workspace/files/preflight`. Authenticate through `authn.FromGin` as
|
||||
`AttachmentHandlers.owner` does, resolve the session to an agentctl client through the lifecycle
|
||||
manager as `RegisterProcessRoutes` does, and stage the bounded file part before forwarding it to the
|
||||
client method.
|
||||
- Register beside `taskhandlers.RegisterProcessRoutes` in
|
||||
`apps/backend/internal/backendapp/helpers.go`.
|
||||
|
||||
HTTP rather than a new WebSocket action, because the WebSocket dispatcher is JSON request and
|
||||
response, so bytes would have to be base64-encoded and buffered whole. That is the same defect the
|
||||
existing download already has, and attachments set the HTTP precedent.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Selection normalization
|
||||
|
||||
- New `apps/web/lib/utils/upload-selection.ts` flattening any selection to `{ file, relativePath }`.
|
||||
A picked folder carries `webkitRelativePath`; a flat multi-file pick yields bare names. Both
|
||||
normalize to one shape so the hook, the dialog, and the API client never branch on how the files
|
||||
were chosen.
|
||||
- An unreadable entry is reported and skipped rather than aborting the batch.
|
||||
|
||||
### Upload client and state
|
||||
|
||||
- New `apps/web/lib/api/domains/workspace-file-api.ts` with `preflightWorkspaceUpload` and
|
||||
`uploadWorkspaceFile`, mirroring `attachment-api.ts`: `FormData` for the upload, JSON for the
|
||||
preflight, `credentials: "include"`, the interim settings interlock header from
|
||||
`readInterimSettingsInterlockToken()`, and `ApiError` on failure.
|
||||
- One request per file, so one rejected file does not fail the rest of a selection.
|
||||
- New `apps/web/hooks/use-file-upload.ts` owning the two-phase flow and returning
|
||||
`{ uploadFiles(dir, selection), uploads, conflicts, resolveConflicts, cancel }`. Per-file status
|
||||
uses the `pending | uploading | ready | failed` vocabulary already in
|
||||
`components/task/chat/file-attachment.ts`, plus `blocked` for a conflict awaiting a decision.
|
||||
- Keep upload state in `apps/web/components/task/use-file-upload-entry-points.tsx`, which mounts one
|
||||
flow for both Files-panel entry points. `use-file-operations.ts` remains the owner of CRUD and
|
||||
download calls.
|
||||
- On success, insert with `insertNodeInTree` as `handleCreateFileSubmit` already does, using the
|
||||
server-reported path; on failure, remove the optimistic node and toast.
|
||||
|
||||
### Conflict resolution dialog
|
||||
|
||||
- New `apps/web/components/task/file-upload-conflict-dialog.tsx` listing conflicting paths with
|
||||
**Replace**, **Keep both**, and **Skip** per file, plus an apply-to-all control.
|
||||
- Cancelling uploads nothing, including the non-conflicting files.
|
||||
- Use the existing dialog primitives; `file-delete-confirmation.tsx` is the nearest neighbour for a
|
||||
destructive-choice dialog in this panel.
|
||||
|
||||
### Upload entry points
|
||||
|
||||
- `apps/web/components/task/file-browser-toolbar.tsx`: the create control becomes a `DropdownMenu`
|
||||
with **New File**, **Upload Files**, and **Upload Folder**. Use the sibling `WorkspaceActionsMenu`
|
||||
in the same file as the template, since it already carries the trigger, tooltip,
|
||||
`min-h-11 ... sm:min-h-8` touch sizing, and the `onCloseAutoFocus` handling. New File must keep
|
||||
firing `onStartCreate` with identical semantics.
|
||||
- `apps/web/components/task/file-context-menu.tsx`: add **Upload files here**, guarded as the exact
|
||||
inverse of the existing download guard at line 114, so it appears for a folder and not for a file
|
||||
or a multi-selection. Thread `onUploadFiles` down the same prop chain as `onDownloadFile`.
|
||||
- Folder picking uses a second hidden input carrying `webkitdirectory`.
|
||||
|
||||
### Download surfaces
|
||||
|
||||
- `apps/web/components/editors/monaco/monaco-editor-toolbar.tsx`: add a download control shaped like
|
||||
its `DeleteButton` neighbour, placed between the open-with dropdown and delete, driven by a new
|
||||
optional `onDownload` prop.
|
||||
- `apps/web/components/editors/codemirror/codemirror-code-editor.tsx`: the same control in the
|
||||
parallel toolbar beside `CodeMirrorDeleteButton`. Doing only one editor recreates the
|
||||
inconsistency this change exists to remove.
|
||||
- `apps/web/components/task/file-editor-panel.tsx`: append a download control to the `headerActions`
|
||||
fragment already passed to `FileBinaryViewer` and `FileImageViewer`. Both viewers and
|
||||
`components/task/mobile/mobile-file-viewer-panel.tsx` consume the same contract, so one change
|
||||
covers all three.
|
||||
- Download from the content already in the dockview store. No refetch.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
- **Desktop outcome and phone entry:** both platforms use the same Files panel and the same viewer
|
||||
header. There is no separate mobile path to build.
|
||||
- **Nearest exemplar:** `WorkspaceActionsMenu` in the file browser toolbar supplies the established
|
||||
touch target sizing and dropdown behavior for the create menu; `FileViewerHeader` supplies the
|
||||
viewer action row; `file-delete-confirmation.tsx` supplies the dialog shape.
|
||||
- **Hierarchy:** upload is an action on the panel chrome; download is an action on the file being
|
||||
viewed. Neither introduces new chrome.
|
||||
- **Presentation:** a dropdown rather than a modal for the create control, matching the adjacent
|
||||
actions menu, so every action stays one tap away and no focus trap is introduced. The conflict
|
||||
dialog is a real dialog because it is a decision, not a navigation.
|
||||
- **Geometry:** existing panel and viewer layout is authoritative. No new scroll owner. The conflict
|
||||
list scrolls within the dialog.
|
||||
|
||||
### Internationalization
|
||||
|
||||
New keys in `apps/web/src/locales/en/task.json`: `uploadFiles`, `uploadFolder`, `uploadFilesHere`,
|
||||
`uploadComplete_one`, `uploadComplete_other`, `uploadedTo`, `failedToUploadFile`,
|
||||
`uploadPartiallyFailed_one`, `uploadPartiallyFailed_other`, `uploadConflictTitle_one`,
|
||||
`uploadConflictTitle_other`, `uploadConflictBody`, `uploadConflictApplyToAll`, `uploadConflictKeepBoth`,
|
||||
`uploadConflictReplace`, `uploadConflictSkip`, `uploadConflictConfirm`. New
|
||||
`downloadFile` in `apps/web/src/locales/en/editors.json`. `newFile` and `task:download` already
|
||||
exist.
|
||||
|
||||
Per the repository internationalization rules, every one of these must also land in `pt-pt`,
|
||||
`zh-cn`, `zh-hk`, and `zh-tw`, since `check-i18n-keys.mjs` fails on a missing key, a dropped
|
||||
placeholder, or a value left identical to English. Use `pnpm run i18n:zh-hant` for the Traditional
|
||||
Chinese pair. No em dashes in any catalog value. Conflict counts use `count` with `_one` / `_other`,
|
||||
never a concatenated plural.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
- **Write primitive containment, resolution, and atomicity**
|
||||
- **File:** `apps/backend/internal/agentctl/server/process/workspace_files_test.go`
|
||||
- **How:** table tests for `..`, absolute paths, a symlinked directory, a `.git/` target, and a
|
||||
crafted interior segment in a folder upload's relative path, each asserting nothing is written.
|
||||
Separate tests for each resolution, for an absent resolution against an existing destination
|
||||
erroring rather than overwriting, for a failed source reader leaving no destination file, for
|
||||
intermediate directory creation, and for the change notification firing.
|
||||
- **Preflight**
|
||||
- **File:** same
|
||||
- **How:** a mixed list returns exactly the existing paths as conflicts, and a containment failure
|
||||
is reported as an error rather than a conflict.
|
||||
- **agentctl handlers**
|
||||
- **File:** `apps/backend/internal/agentctl/server/api/workspace_upload_test.go`
|
||||
- **How:** oversize returns `413`, unresolved existing destination returns `409`, containment
|
||||
rejection returns `400`, a well-formed request returns `201` with the written path, and a
|
||||
declared size that disagrees with the received bytes is rejected.
|
||||
- **Backend client round trip**
|
||||
- **File:** `apps/backend/internal/agent/runtime/agentctl/client_workspace_upload_test.go`
|
||||
- **How:** mirror `client_attachments_test.go` against an `httptest` server, asserting the
|
||||
multipart body arrives intact and the error body is surfaced.
|
||||
- **Backend HTTP routes**
|
||||
- **File:** `apps/backend/internal/task/handlers/workspace_file_http_handlers_test.go`
|
||||
- **How:** unauthenticated is refused, unknown session is refused, a valid request forwards.
|
||||
- **Selection normalization**
|
||||
- **File:** `apps/web/lib/utils/upload-selection.test.ts`
|
||||
- **How:** `webkitRelativePath` entries flatten to the expected shape, nested directories keep
|
||||
their relative paths, and an unreadable entry is skipped rather than fatal.
|
||||
- **Web API client and hook**
|
||||
- **Files:** `apps/web/lib/api/domains/workspace-file-api.test.ts`,
|
||||
`apps/web/hooks/use-file-upload.test.ts`
|
||||
- **How:** form fields and headers are correct, a non-OK response raises `ApiError`, no-conflict
|
||||
selections skip the dialog, cancelling uploads nothing, Skip omits its file, and one failing file
|
||||
does not fail the others.
|
||||
- **Conflict dialog**
|
||||
- **File:** `apps/web/components/task/file-upload-conflict-dialog.test.tsx`
|
||||
- **How:** every conflict is listed, per-file and apply-to-all resolutions are collected, and
|
||||
cancel reports cancellation.
|
||||
- **Upload entry points**
|
||||
- **Files:** `apps/web/components/task/file-browser-toolbar.test.tsx`,
|
||||
`apps/web/components/task/file-context-menu.test.tsx`
|
||||
- **How:** the create menu renders all three items and New File still triggers inline creation;
|
||||
Upload appears for a folder and is absent for a file and for a multi-selection.
|
||||
- **Download surfaces**
|
||||
- **Files:** `apps/web/components/editors/monaco/monaco-editor-toolbar.test.tsx`, a new CodeMirror
|
||||
toolbar test, and a new `apps/web/components/task/file-editor-panel.download.test.tsx`
|
||||
- **How:** each control invokes its handler; both viewers render the action from `headerActions`;
|
||||
binary content round-trips without corruption.
|
||||
|
||||
---
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** Upload files through the create menu and see them appear in the tree.
|
||||
- **File:** `apps/web/e2e/tests/task/workspace-file-transfer.spec.ts`
|
||||
- **What to verify:** `setInputFiles` against the hidden input, the nodes appear at the destination
|
||||
without a refresh, and the confirmation names the written paths.
|
||||
- **Scenario:** Upload onto existing names and resolve the conflicts.
|
||||
- **File:** same
|
||||
- **What to verify:** the dialog lists exactly the conflicts, Replace overwrites, Keep both
|
||||
produces the `-1` name beside the untouched original, Skip writes nothing, and cancel writes
|
||||
nothing at all.
|
||||
- **Scenario:** Upload a folder and see its structure recreated.
|
||||
- **File:** same
|
||||
- **What to verify:** nested directories exist at the right relative paths.
|
||||
- **Scenario:** Open an unpreviewable file and download it from that screen.
|
||||
- **File:** same
|
||||
- **What to verify:** the control is present on the binary viewer and the bytes match the source.
|
||||
|
||||
`file-tree-multi-select.spec.ts` is the nearest existing neighbour for the tree interactions.
|
||||
|
||||
---
|
||||
|
||||
## Public documentation
|
||||
|
||||
The Files panel has no dedicated public page today, and this adds no CLI command, configuration key,
|
||||
or deployment concern. No public documentation change is planned. If a Files-panel page is added
|
||||
later, upload and download belong on it.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Execution remains sequential in the primary conversation unless the user authorizes otherwise.
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [task-01-agentctl-file-write](task-01-agentctl-file-write.md)
|
||||
- [x] [task-02-editor-download-actions](task-02-editor-download-actions.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [task-03-backend-upload-route](task-03-backend-upload-route.md)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [x] [task-04-web-upload-client](task-04-web-upload-client.md)
|
||||
|
||||
Wave 4:
|
||||
|
||||
- [x] [task-05-conflict-resolution-dialog](task-05-conflict-resolution-dialog.md)
|
||||
|
||||
Wave 5:
|
||||
|
||||
- [x] [task-06-upload-entry-points](task-06-upload-entry-points.md)
|
||||
|
||||
Wave 6:
|
||||
|
||||
- [ ] [task-07-file-transfer-e2e](task-07-file-transfer-e2e.md)
|
||||
|
||||
Tasks 01 and 02 are genuinely parallel-safe: they share no file and no contract, because download
|
||||
needs no transport. Everything from 03 onward is a chain up the upload stack.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- **Containment is the whole security story, and folder upload widens it.** The client now supplies
|
||||
interior path segments, not just a file name. Every segment must resolve through
|
||||
`resolveMutationPath`, including during intermediate directory creation. The table tests exist to
|
||||
make that non-negotiable.
|
||||
- **The file tree's existing drag-to-move is untouched.** No external-drop branch is added, so the
|
||||
known side-panel issues are not compounded. If drag and drop is revived later, note that an
|
||||
external drag currently dies in `handleDragOver` because `dragPathsRef` is empty and
|
||||
`allSameParent` is vacuously true, so the new branch would have to run ahead of that early return.
|
||||
- **Two-phase upload is not a lock.** Preflight informs a decision; it does not reserve the path. The
|
||||
`409` on an unresolved existing destination is the actual safety property. Treating preflight as
|
||||
authoritative would reintroduce silent overwrite under a race.
|
||||
- **Cancel must mean nothing was written.** Because the dialog appears after preflight and before any
|
||||
upload, this is achievable, but only if the flow never starts uploading non-conflicting files
|
||||
early as an optimization.
|
||||
- **Regressing New File.** Inline creation is currently one click on the `+`. It becomes two through
|
||||
the menu. Keep `onStartCreate` semantics byte-identical and cover the existing flow in the toolbar
|
||||
test.
|
||||
- **Two editor toolbars.** Monaco and CodeMirror both need the download control in the same change.
|
||||
- **Writing under a running agent.** The mutation barrier plus temp-then-rename covers atomicity, but
|
||||
an upload during an active turn can still surprise the agent. Surface the destination path in the
|
||||
confirmation.
|
||||
- **Translation gate.** Five locales, and the key check fails the build on a missing or untranslated
|
||||
value. Budget for it in the task that adds the copy.
|
||||
- **`pnpm run typecheck` OOMs at the default heap on this host.** It needs roughly 4 GB; run it as
|
||||
`node --max-old-space-size=8192 node_modules/typescript/bin/tsc --noEmit`, because the repo's mise
|
||||
`[env]` block overwrites an inherited `NODE_OPTIONS`.
|
||||
- **No rollout toggle.** Reasoning is in the system design: the workspace is already fully
|
||||
agent-writable, so upload adds an authenticated route to an existing capability. If that judgment
|
||||
is not wanted, add the flag before task 03 lands, since the backend route is the natural gate.
|
||||
|
||||
---
|
||||
|
||||
## Manual verification (2026-09-02)
|
||||
|
||||
Driven with Playwright against a live dev instance at `v0.92.2-68-gc51ec0a21`, exercising the real
|
||||
UI rather than mocks.
|
||||
|
||||
| Scenario | Result |
|
||||
| --- | --- |
|
||||
| Create menu shows New file / Upload files / Upload folder | pass |
|
||||
| Upload two files, appear in tree without refresh | pass |
|
||||
| Repeat upload raises the conflict dialog before any write | pass |
|
||||
| Keep both writes `name-1.ext` beside an untouched original | pass |
|
||||
| Folder upload recreates `cpu-z/docs/` and both executables | pass |
|
||||
| Download from the image viewer | pass |
|
||||
| Download from the unpreviewable-file screen | pass |
|
||||
| Upload item on folder right-click | pass |
|
||||
| Editor header download, text file | pass |
|
||||
| Tree right-click download unchanged | pass |
|
||||
|
||||
A 5,478,310-byte zip uploaded and downloaded byte-for-byte identical, exercising the streamed
|
||||
multipart write and the base64 binary download path on a real 5.4 MB file.
|
||||
|
||||
PR media (10 stills, an MP4, and a GIF) was captured from this run.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
id: "01-agentctl-file-write"
|
||||
title: "agentctl streamed workspace file write and conflict preflight"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-001
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-003
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-004
|
||||
acceptance_criteria:
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.1
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.2
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.5
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.6
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.1
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.6
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/workspace-file-transfer.md
|
||||
---
|
||||
|
||||
# Task 01: agentctl streamed workspace file write and conflict preflight
|
||||
|
||||
## Summary
|
||||
|
||||
Add the single write-bytes primitive the workspace has never had, its conflict preflight, and the two
|
||||
agentctl routes over them. This is the bottom of the upload stack; nothing above it can exist first.
|
||||
|
||||
## Scope
|
||||
|
||||
- `WorkspaceTracker.WriteFileStream` in `server/process/workspace_files.go`, taking a destination, a
|
||||
resolution, and an `io.Reader`. It resolves through the existing `resolveMutationPath`, holds
|
||||
`runWorkspaceMutationBarrier`, creates missing intermediate directories through the same rooted
|
||||
handle, applies the resolution, writes to a temporary file in the destination directory, fsyncs,
|
||||
renames into place, and emits the change notification through `mutationNotificationPath`.
|
||||
- Resolutions: `replace` overwrites; `keep_both` selects the next free `name-<n>.ext`, reusing the
|
||||
search in `installMaterializedAttachment`; an absent resolution against an existing destination is
|
||||
an error, never a silent overwrite.
|
||||
- `WorkspaceTracker.CheckUploadConflicts`, resolving a candidate path list through the same rules and
|
||||
reporting which already exist. A containment failure is an error, not a conflict.
|
||||
- `POST /api/v1/workspace/file/upload-preflight` (JSON) and `POST /api/v1/workspace/file/upload` (multipart) in a
|
||||
new `server/api/workspace_upload.go`, registered beside the existing workspace file routes. The
|
||||
upload route is bounded by `http.MaxBytesReader` and streams the `file` part.
|
||||
- Status mapping: `413` oversize, `409` unresolved existing destination, `400` containment rejection
|
||||
or size mismatch, `500` IO.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- The backend client method and the backend HTTP routes. Those are task 03.
|
||||
- Any frontend change.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A destination that escapes the workspace by traversal, absolute path, symlinked directory, or a
|
||||
`.git/` target is rejected and writes nothing. This holds for every interior segment of a folder
|
||||
upload's relative path, not only the destination folder.
|
||||
- Each resolution behaves as specified, and an absent resolution against an existing destination
|
||||
errors rather than overwriting. Concurrent uploads choosing `keep_both` for the same name do not
|
||||
collide, because the name search and the write share one mutation barrier.
|
||||
- A reader that fails mid-stream leaves no file at the destination and no temporary file behind.
|
||||
|
||||
## Verification
|
||||
|
||||
Write the containment table test first, including the crafted interior-segment case, and confirm it
|
||||
fails before the production change. Then:
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test ./internal/agentctl/server/process -run 'TestWriteFileStream|TestCheckUploadConflicts'
|
||||
go test ./internal/agentctl/server/api -run 'TestHandleFileUpload|TestHandleUploadPreflight'
|
||||
gofmt -l internal/agentctl/server/process internal/agentctl/server/api
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agentctl/server/process/workspace_files.go`
|
||||
- `apps/backend/internal/agentctl/server/process/workspace_files_test.go`
|
||||
- `apps/backend/internal/agentctl/server/api/workspace_upload.go`
|
||||
- `apps/backend/internal/agentctl/server/api/workspace_upload_test.go`
|
||||
- `apps/backend/internal/agentctl/server/api/server.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with task 02, which shares no file and no contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements: `REQ-UI-WORKSPACE-FILE-TRANSFER-003` and `-004`.
|
||||
- System design: `Components and responsibilities > Backend`, `Data and contracts`, `Security`.
|
||||
- Existing patterns: `CreateFile` and `DeleteFile` for the mutation shape,
|
||||
`handleMaterializeAttachment` for the bounded multipart handler, and
|
||||
`installMaterializedAttachment` for the `name-<n>.ext` search.
|
||||
|
||||
## Risks
|
||||
|
||||
- Resolving paths in the handler instead of delegating to `resolveMutationPath` reopens traversal and
|
||||
symlink escape. Folder upload makes this sharper: the client now supplies interior segments.
|
||||
- Creating intermediate directories outside the rooted handle defeats the containment guarantee even
|
||||
when the final path check passes.
|
||||
- Selecting the `keep_both` name outside the mutation barrier makes concurrent uploads race.
|
||||
- Buffering the part to check its size defeats streaming. Validate against the byte count as it
|
||||
streams.
|
||||
- Treating preflight as authoritative would let a race reintroduce silent overwrite. The `409` is the
|
||||
real guarantee.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the primitive's signature, the resolution semantics, the status mapping, files changed, exact
|
||||
commands and results, then mark this task `done` and update its checkbox in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
Implemented on `v0.92.2-68-gc51ec0a21`.
|
||||
|
||||
- `WriteFileStream` and `CheckUploadConflicts` in new
|
||||
`apps/backend/internal/agentctl/server/process/workspace_upload.go`, with `UploadResolution`
|
||||
(`""` / `replace` / `keep_both`) and a distinct `ErrUploadConflict` so the HTTP layer can map 409.
|
||||
- Staging is a `.kandev-upload-<pid>-<n>` temporary in the destination directory, opened through the
|
||||
rooted handle, then fsync and rename. Every error path removes it.
|
||||
- `keep_both` produces `name-1.ext`, matching the existing attachment installer. The Windows
|
||||
`name (1).ext` form was considered and rejected: these land in a code workspace, where a space and
|
||||
parentheses need quoting in every shell command and show up in git paths.
|
||||
- Two agentctl routes in new `server/api/workspace_upload.go`, registered in `server.go` beside
|
||||
`/workspace/file/create`.
|
||||
|
||||
**Correction made during implementation:** the first cut of `scopedUploadPath` normalized `../x` to
|
||||
`x` instead of rejecting it. It was contained, but it silently relocated the file, which contradicts
|
||||
`AC-UI-WORKSPACE-FILE-TRANSFER-003.1`. Replaced with `sanitizeUploadSegment`, which rejects absolute
|
||||
paths and any `..` component outright. The containment test caught this.
|
||||
|
||||
### Commands
|
||||
|
||||
```text
|
||||
go test ./internal/agentctl/server/process -run 'TestWriteFileStream|TestCheckUploadConflicts' ok (16 subtests)
|
||||
go test ./internal/agentctl/server/api -run 'TestHandleFileUpload|TestHandleUploadPreflight' ok (10 tests)
|
||||
go test ./internal/agentctl/server/process ./internal/agentctl/server/api ok
|
||||
gofmt -l ... clean
|
||||
golangci-lint run ... 0 issues
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
id: "02-editor-download-actions"
|
||||
title: "Download actions on editor and viewer surfaces"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-002
|
||||
acceptance_criteria:
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.1
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.2
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.3
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.5
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.6
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/workspace-file-transfer.md
|
||||
---
|
||||
|
||||
# Task 02: Download actions on editor and viewer surfaces
|
||||
|
||||
## Summary
|
||||
|
||||
Put download where people are actually looking at the file: both editor headers, the
|
||||
unpreviewable-file screen, and the image viewer. Entirely frontend, no transport work, because the
|
||||
viewer already holds the content.
|
||||
|
||||
## Scope
|
||||
|
||||
- A download control in `monaco-editor-toolbar.tsx`, shaped like its `DeleteButton` neighbour and
|
||||
placed between the open-with dropdown and delete, driven by a new optional `onDownload` prop.
|
||||
- The same control in the parallel CodeMirror toolbar in `codemirror-code-editor.tsx`.
|
||||
- A download control appended to the `headerActions` fragment in `file-editor-panel.tsx`, which
|
||||
`FileBinaryViewer`, `FileImageViewer`, and the mobile file viewer all consume.
|
||||
- Download from the content already in the dockview store, through the existing
|
||||
`triggerFileDownload`.
|
||||
- The `editors:downloadFile` key in all five locales.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- Any upload work.
|
||||
- A streaming download endpoint, folder download, or multi-select download.
|
||||
- The separate defect where a file above the read cap hangs on the loading state.
|
||||
- Changing the existing tree right-click download.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Both editors show a download control with the same placement, icon, and accessible name, and each
|
||||
invokes its handler for the open file.
|
||||
- The unpreviewable-file screen and the image viewer both render the control from the shared
|
||||
`headerActions` contract, and the mobile viewer inherits it.
|
||||
- A downloaded binary file keeps its original bytes and original file name.
|
||||
- The existing single-file download in the tree's right-click menu keeps its current behavior and
|
||||
placement, proven by a regression assertion rather than by inspection.
|
||||
|
||||
## Verification
|
||||
|
||||
Write the toolbar and viewer assertions first and confirm they fail before the production change.
|
||||
Then:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- components/editors/monaco/monaco-editor-toolbar.test.tsx components/task/file-editor-panel.download.test.tsx
|
||||
cd apps/web && pnpm run typecheck && pnpm run i18n:check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/editors/monaco/monaco-editor-toolbar.tsx` and its test
|
||||
- `apps/web/components/editors/codemirror/codemirror-code-editor.tsx` and a new toolbar test
|
||||
- `apps/web/components/task/file-editor-panel.tsx`
|
||||
- `apps/web/components/task/file-editor-panel.download.test.tsx`
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/editors.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Parallel-safe with task 01. Land this first if a standalone quick win is wanted; it is independently
|
||||
shippable.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements: `REQ-UI-WORKSPACE-FILE-TRANSFER-002`.
|
||||
- System design: `Components and responsibilities > Frontend`, `Control flow` on why no backend is
|
||||
needed.
|
||||
- Existing patterns: `DeleteButton` and `CodeMirrorDeleteButton` for the control shape,
|
||||
`FileViewerHeader` for the action row, `triggerFileDownload` for the transfer.
|
||||
|
||||
## Risks
|
||||
|
||||
- Doing only Monaco leaves CodeMirror inconsistent, which is the bug this task exists to remove.
|
||||
- Refetching instead of using the loaded content adds a failure mode the requirement excludes.
|
||||
- `triggerFileDownload` expects base64 for binary content, matching the `workspace.file.get`
|
||||
contract. Passing decoded bytes corrupts the download.
|
||||
- Five locales are required; `check-i18n-keys.mjs` fails on a value left identical to English.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report each surface that gained the control, files changed, exact commands and results, then mark
|
||||
this task `done` and update its checkbox in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- `DownloadButton` in `monaco-editor-toolbar.tsx` and `CodeMirrorDownloadButton` in
|
||||
`codemirror-code-editor.tsx`, both between open-with and delete, driven by a new optional
|
||||
`onDownload` threaded through `file-editor-content.tsx` and `monaco-code-editor.tsx`.
|
||||
- `FileViewerDownloadButton` in `file-viewer-header.tsx`, appended to the `headerActions` fragment in
|
||||
`file-editor-panel.tsx`, which lights up the binary viewer, the image viewer, and the mobile viewer
|
||||
from one place.
|
||||
- Download reads the buffer already in the dockview store via `useOpenFileDownload` / a `useMemo` in
|
||||
the editor path. No refetch, and binary content stays base64 for `triggerFileDownload`.
|
||||
- `editors:downloadFile` added in en, pt-pt, zh-cn, zh-hk, zh-tw plus the regenerated pseudo catalog.
|
||||
|
||||
**Regression found and fixed:** the new tooltip broke `file-editor-panel.image.test.tsx`, which
|
||||
rendered `StaticFilePanel` without a `TooltipProvider`. Production is fine (`app-shell.tsx` and
|
||||
`app/layout.tsx` both provide one); the test had only escaped it by mocking the single component that
|
||||
used a tooltip. Its renders are now wrapped.
|
||||
|
||||
### Commands
|
||||
|
||||
```text
|
||||
pnpm --filter @kandev/web test -- components/editors/ components/task/file-editor-panel 37 passed
|
||||
pnpm run i18n:check 6/6 gates pass
|
||||
pnpm run lint 0 problems
|
||||
node ../node_modules/typescript/bin/tsc --noEmit clean
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
id: "03-backend-upload-route"
|
||||
title: "Backend upload client and session route"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-agentctl-file-write"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-001
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-003
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-004
|
||||
acceptance_criteria:
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.2
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.3
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-003.5
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.1
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/workspace-file-transfer.md
|
||||
---
|
||||
|
||||
# Task 03: Backend upload client and session route
|
||||
|
||||
## Summary
|
||||
|
||||
Connect the browser to the agentctl write path from task 01: a streaming client method and the
|
||||
authenticated session-scoped HTTP route that uses it.
|
||||
|
||||
## Scope
|
||||
|
||||
- `Client.UploadWorkspaceFile` and `Client.PreflightWorkspaceUpload` in a new
|
||||
`client_workspace_upload.go`. The upload method copies the structure of `MaterializeAttachment`:
|
||||
`io.Pipe`, a goroutine writing the multipart body, the long-running HTTP client, a bounded
|
||||
error-body read.
|
||||
- `POST /api/v1/task-sessions/:id/workspace/files` and
|
||||
`POST /api/v1/task-sessions/:id/workspace/files/preflight` in a new
|
||||
`internal/task/handlers/workspace_file_http_handlers.go`, authenticating through `authn.FromGin`,
|
||||
resolving the session to an agentctl client through the lifecycle manager, and forwarding a bounded
|
||||
temporary file after the declared size is checked.
|
||||
- Registration beside `taskhandlers.RegisterProcessRoutes` in `backendapp/helpers.go`.
|
||||
- The per-file cap is `models.MaxMessageAttachmentBytes`.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- Any frontend change.
|
||||
- Changing the agentctl handler or the write primitive from task 01.
|
||||
- A new WebSocket action. The transport is HTTP because the WebSocket dispatcher would force base64
|
||||
and whole-file buffering.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Both routes refuse an unauthenticated request and a request for an unknown or unreachable session,
|
||||
before anything is streamed.
|
||||
- The preflight forwards its path list and returns the conflict set unchanged; the upload forwards
|
||||
the resolution so an unresolved existing destination still surfaces as `409`.
|
||||
- A single file above the attachment limit is rejected with a size-specific status, and one request
|
||||
carries one file so a rejection does not affect other files in a selection.
|
||||
- Network bodies are streamed and bounded. Metadata and error responses use limited reads, and
|
||||
nothing is base64-encoded; file bytes do not enter a whole-file memory buffer.
|
||||
|
||||
## Verification
|
||||
|
||||
Write the auth-refusal and oversize tests first and confirm they fail before the production change.
|
||||
Then:
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
go test ./internal/agent/runtime/agentctl -run 'TestUploadWorkspaceFile|TestPreflightWorkspaceUpload'
|
||||
go test ./internal/task/handlers -run 'TestWorkspaceFileUpload|TestWorkspaceFilePreflight'
|
||||
gofmt -l internal/agent/runtime/agentctl internal/task/handlers internal/backendapp
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agent/runtime/agentctl/client_workspace_upload.go` and its test
|
||||
- `apps/backend/internal/task/handlers/workspace_file_http_handlers.go` and its test
|
||||
- `apps/backend/internal/backendapp/helpers.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. It calls the route task 01 introduces.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements: `REQ-UI-WORKSPACE-FILE-TRANSFER-003`.
|
||||
- System design: `Data and contracts`, `Control flow`, `Security`.
|
||||
- Existing patterns: `MaterializeAttachment` for the streaming client, `AttachmentHandlers.owner` for
|
||||
the auth guard, `RegisterProcessRoutes` for session-to-execution resolution and registration.
|
||||
|
||||
## Risks
|
||||
|
||||
- Buffering the part to compute a size defeats streaming. Pass the declared size through and let the
|
||||
agentctl layer validate against received bytes.
|
||||
- agentctl may not be same-host in a containerized or remote executor. Stay on the existing agentctl
|
||||
HTTP client, which already handles that.
|
||||
- If a rollout toggle is wanted after all, this route is the natural gate; add it here rather than
|
||||
retrofitting later.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the route path, its auth and size behavior, files changed, exact commands and results, then
|
||||
mark this task `done` and update its checkbox in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- `UploadWorkspaceFile` and `PreflightWorkspaceUpload` in new
|
||||
`internal/agent/runtime/agentctl/client_workspace_upload.go`, copying `MaterializeAttachment`'s
|
||||
`io.Pipe` + goroutine multipart structure. `ErrWorkspaceUploadConflict` is a sentinel so a 409
|
||||
stays distinguishable from a failure.
|
||||
- `POST /api/v1/task-sessions/:id/workspace/files` and `.../files/preflight` in new
|
||||
`internal/task/handlers/workspace_file_http_handlers.go`, guarded by the existing
|
||||
`denySessionAccess` and resolving the client through `execution.AcquireAgentCtlClient()` after
|
||||
bounded file validation.
|
||||
- `RegisterProcessRoutes` now returns `*ProcessHandlers` so `helpers.go` can register the workspace
|
||||
routes beside it without constructing a second handler set.
|
||||
- The upload handler validates `size_bytes` against the actual part size and caps at
|
||||
`models.MaxMessageAttachmentBytes`; a session with no live execution returns 503.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
go test ./internal/agent/runtime/agentctl -run 'TestUploadWorkspaceFile|TestPreflightWorkspaceUpload' ok
|
||||
go test ./internal/task/handlers -run 'TestWorkspaceFile|TestRegisterWorkspaceFileRoutes' ok (6)
|
||||
go test ./internal/task/handlers ./internal/agent/runtime/agentctl ok
|
||||
go build ./... ok
|
||||
gofmt -l internal/ clean
|
||||
golangci-lint run ./internal/task/handlers/... ./internal/agent/runtime/agentctl/... 0 issues
|
||||
```
|
||||
|
||||
The cross-user tests assert the denial lands **before** agentctl is contacted, with the owner request
|
||||
as the witness that the 404 is scoping rather than an unrelated failure.
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
id: "04-web-upload-client"
|
||||
title: "Web upload client, selection normalization, and two-phase hook"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "03-backend-upload-route"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-001
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-004
|
||||
acceptance_criteria:
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.3
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.5
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.6
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.2
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.5
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/workspace-file-transfer.md
|
||||
---
|
||||
|
||||
# Task 04: Web upload client, selection normalization, and two-phase hook
|
||||
|
||||
## Summary
|
||||
|
||||
The frontend transport and flow control: normalize any selection into destination-relative paths,
|
||||
call the preflight, and drive the uploads. No user-visible entry point yet, and no dialog yet; this
|
||||
is the machinery both depend on.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/web/lib/utils/upload-selection.ts` flattening a selection to `{ file, relativePath }`. A
|
||||
picked folder carries `webkitRelativePath`; a flat multi-file pick yields bare names. Both normalize
|
||||
to one shape, so callers never branch on how the files were chosen.
|
||||
- `apps/web/lib/api/domains/workspace-file-api.ts` with `preflightWorkspaceUpload` (JSON) and
|
||||
`uploadWorkspaceFile` (`FormData`), mirroring `attachment-api.ts` for `credentials: "include"`, the
|
||||
interim settings interlock header, and `ApiError` on a non-OK response.
|
||||
- One upload request per file, so a rejected file does not fail the rest of a selection.
|
||||
- `apps/web/hooks/use-file-upload.ts` returning
|
||||
`{ uploadFiles(dir, selection), uploads, conflicts, resolveConflicts, cancel }`, with per-file
|
||||
status `pending | uploading | ready | failed` plus `blocked` for a conflict awaiting a decision.
|
||||
- Upload state is owned by `use-file-upload-entry-points.tsx`, which mounts one flow for both
|
||||
Files-panel entry points. `use-file-operations.ts` keeps CRUD and download calls.
|
||||
- Optimistic insertion with `insertNodeInTree`, replaced with the server-reported path on success and
|
||||
removed on failure.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- The conflict dialog component. That is task 05; this task exposes the state it binds to.
|
||||
- The toolbar menu and context-menu item. Those are task 06.
|
||||
- Download work.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A selection with no conflict uploads without ever entering the blocked state, and a selection with
|
||||
conflicts stops before any upload request is sent.
|
||||
- Cancelling resolution sends no upload request at all, including for files that had no conflict.
|
||||
- Per-file status transitions are observable, one failing file leaves the others' results intact, and
|
||||
a successful file is inserted at the server-reported path rather than the requested one.
|
||||
|
||||
## Verification
|
||||
|
||||
Write the flattening and flow-control tests first and confirm they fail before the production change.
|
||||
Then:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- lib/utils/upload-selection.test.ts lib/api/domains/workspace-file-api.test.ts hooks/use-file-upload.test.ts
|
||||
cd apps/web && node ../node_modules/typescript/bin/tsc --noEmit
|
||||
```
|
||||
|
||||
The typecheck is spelled out because `pnpm run typecheck` exhausts the default heap on this host.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/utils/upload-selection.ts` and its test
|
||||
- `apps/web/lib/api/domains/workspace-file-api.ts` and its test
|
||||
- `apps/web/hooks/use-file-upload.ts` and its test
|
||||
- `apps/web/hooks/use-file-operations.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 03.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. It calls the routes task 03 introduces.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements: `REQ-UI-WORKSPACE-FILE-TRANSFER-001` and `-004`.
|
||||
- System design: `Components and responsibilities > Frontend`, `Control flow`.
|
||||
- Existing patterns: `uploadAttachment` for the request shape, `file-attachment.ts` for the status
|
||||
vocabulary, `handleCreateFileSubmit` for optimistic tree insertion.
|
||||
|
||||
## Risks
|
||||
|
||||
- Starting non-conflicting uploads while the dialog is open would break the cancel guarantee. Do not
|
||||
optimize that way.
|
||||
- `fetch` has no upload progress event. If per-file progress is wanted rather than a running
|
||||
indication, `XMLHttpRequest` is required; decide before building the status model.
|
||||
- The server-reported path is authoritative after `keep_both`. Inserting the requested name leaves
|
||||
the tree wrong.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the normalized selection shape, the hook's state machine, files changed, exact commands and
|
||||
results, then mark this task `done` and update its checkbox in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- `lib/utils/upload-selection.ts` normalizes both pickers to `{ file, relativePath }`, rejecting
|
||||
absolute paths and any `..` segment per entry rather than failing the batch.
|
||||
- `lib/api/domains/workspace-file-api.ts` with `preflightWorkspaceUpload` (JSON) and
|
||||
`uploadWorkspaceFile` (`FormData`), one request per file, `ApiError` preserving the server message
|
||||
and status so a 409 stays distinguishable.
|
||||
- `hooks/use-file-upload.ts` owns the two-phase flow. The batch is parked in a promise while the
|
||||
dialog decides; `resolveConflicts` applies per-file choices and `cancelConflicts` resolves with
|
||||
`cancelled: true` having uploaded nothing.
|
||||
- Upload state is mounted by `use-file-upload-entry-points.tsx`, so both Files-panel entry points
|
||||
share one flow while `use-file-operations.ts` remains focused on CRUD and download calls.
|
||||
|
||||
**Cancel is proven, not assumed.** `uploadWorkspaceFile` is asserted never-called after a cancel,
|
||||
including for the file in the selection that had no conflict. That is the assertion that forbids the
|
||||
tempting optimization of uploading unconflicted files while the dialog is open.
|
||||
|
||||
Refactored `performUploads` / `buildUploadItems` / `destinationIndex` out of the hook body to stay
|
||||
under the 100-line function limit.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
pnpm --filter @kandev/web test -- lib/utils/upload-selection.test.ts lib/api/domains/workspace-file-api.test.ts hooks/use-file-upload.test.ts 26 passed
|
||||
node ../node_modules/typescript/bin/tsc --noEmit clean
|
||||
pnpm run lint 0 problems
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
id: "05-conflict-resolution-dialog"
|
||||
title: "Upload conflict resolution dialog"
|
||||
status: done
|
||||
wave: 4
|
||||
depends_on:
|
||||
- "04-web-upload-client"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-004
|
||||
acceptance_criteria:
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.3
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.5
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/workspace-file-transfer.md
|
||||
---
|
||||
|
||||
# Task 05: Upload conflict resolution dialog
|
||||
|
||||
## Summary
|
||||
|
||||
The decision surface. When the preflight from task 04 reports conflicts, this shows the person
|
||||
exactly what already exists and collects a choice per file before anything is written.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/web/components/task/file-upload-conflict-dialog.tsx` listing every conflicting destination
|
||||
path with **Replace**, **Keep both**, and **Skip** per file.
|
||||
- An apply-to-all control that sets one resolution across every remaining conflict.
|
||||
- Confirm returns the resolution map to the hook; cancel reports cancellation, which uploads nothing.
|
||||
- The conflict list scrolls within the dialog rather than growing it, since a folder upload can
|
||||
produce many conflicts.
|
||||
- Copy in all five locales, with conflict counts using `count` and `_one` / `_other` rather than a
|
||||
concatenated plural.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- The preflight call and the upload loop. Task 04 owns those; this binds to the state it exposes.
|
||||
- Entry points and drag and drop.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every conflict reported by the preflight is listed, and a resolution can be set per file or applied
|
||||
to all remaining at once.
|
||||
- Confirming returns exactly the chosen resolutions, with **Skip** removing that file from the batch.
|
||||
- Cancelling results in no upload request being sent for any file in the selection.
|
||||
|
||||
## Verification
|
||||
|
||||
Write the dialog assertions first and confirm they fail before the production change. Then:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- components/task/file-upload-conflict-dialog.test.tsx hooks/use-file-upload.test.ts
|
||||
cd apps/web && node ../node_modules/typescript/bin/tsc --noEmit && pnpm run i18n:check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/file-upload-conflict-dialog.tsx` and its test
|
||||
- `apps/web/hooks/use-file-upload.ts`
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 04.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements: `REQ-UI-WORKSPACE-FILE-TRANSFER-004`.
|
||||
- System design: `Components and responsibilities > Frontend`, `Control flow`.
|
||||
- Plan: `Frontend > Conflict resolution dialog` and the mobile design contract.
|
||||
- Existing patterns: `file-delete-confirmation.tsx` as the nearest destructive-choice dialog in this
|
||||
panel.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Replace** is destructive and irreversible; there is no version history behind it. It must not be
|
||||
the pre-selected default, and the copy must make the consequence plain.
|
||||
- A long conflict list must scroll inside the dialog, or the confirm control leaves the viewport on a
|
||||
phone.
|
||||
- Do not translate any value compared with `===`; the resolution tokens are persisted decisions, not
|
||||
copy.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the dialog's resolution contract, files changed, exact commands and results, then mark this
|
||||
task `done` and update its checkbox in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- `components/task/file-upload-conflict-dialog.tsx` lists every conflicting destination with a
|
||||
per-file **Keep both / Replace / Skip** group and an apply-to-all row. The list scrolls inside the
|
||||
dialog (`max-h-64 overflow-y-auto`) so a folder upload with many conflicts keeps the footer
|
||||
reachable.
|
||||
- **Keep both is the default.** Replace is irreversible with no version history behind it, so it is
|
||||
never pre-selected.
|
||||
- Eight `task:uploadConflict*` keys in all five locales plus pseudo, with the title using
|
||||
`_one`/`_other` on `count`. The three resolution tokens are `// i18n-exempt` wire values.
|
||||
|
||||
**Design change during implementation:** the first cut used a Radix `Select` per row. Radix Select
|
||||
is portal-based and effectively undrivable in jsdom without `@testing-library/user-event`, which is
|
||||
not a dependency here. Replaced with a segmented `Button` group carrying `aria-pressed`, which is
|
||||
simpler, keeps all three options visible at a glance for a three-way choice, and is directly
|
||||
testable. A test asserts the `aria-pressed` state so the active choice stays exposed to assistive
|
||||
technology.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
pnpm --filter @kandev/web test -- components/task/file-upload-conflict-dialog.test.tsx 7 passed
|
||||
node ../node_modules/typescript/bin/tsc --noEmit clean
|
||||
pnpm run i18n:check 6/6 gates
|
||||
pnpm run lint 0 problems
|
||||
```
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
id: "06-upload-entry-points"
|
||||
title: "Files panel upload entry points"
|
||||
status: done
|
||||
wave: 5
|
||||
depends_on:
|
||||
- "05-conflict-resolution-dialog"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.1
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.2
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.3
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.7
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/workspace-file-transfer.md
|
||||
---
|
||||
|
||||
# Task 06: Files panel upload entry points
|
||||
|
||||
## Summary
|
||||
|
||||
Make upload reachable: a create menu on the panel toolbar and an upload item on the folder
|
||||
right-click menu, both wired to the flow from tasks 04 and 05. This is the change a person actually
|
||||
sees.
|
||||
|
||||
## Scope
|
||||
|
||||
- `file-browser-toolbar.tsx`: the create control becomes a `DropdownMenu` with **New File**, **Upload
|
||||
Files**, and **Upload Folder**, using the sibling `WorkspaceActionsMenu` as the template for the
|
||||
trigger, tooltip, `min-h-11 ... sm:min-h-8` touch sizing, and `onCloseAutoFocus` handling.
|
||||
- Two hidden inputs: one `multiple`, one carrying `webkitdirectory`. Destination is the active folder,
|
||||
or the workspace root when there is none.
|
||||
- `file-context-menu.tsx`: **Upload files here** guarded as the exact inverse of the existing download
|
||||
guard, present for a folder and absent for a file or a multi-selection, threaded down the same prop
|
||||
chain as `onDownloadFile`.
|
||||
- Both surfaces absent when there is no active task session.
|
||||
- Confirmation names the written paths.
|
||||
- The `task` namespace copy in all five locales.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- Drag and drop from the operating system. Explicitly out of scope for this capability.
|
||||
- The conflict dialog itself, which task 05 owns.
|
||||
- Download work.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The create menu offers all three items; **New File** still begins inline creation with unchanged
|
||||
semantics, and the two upload items open the correct picker.
|
||||
- **Upload files here** appears on a folder right-click, is absent for files and multi-selections, and
|
||||
targets that folder.
|
||||
- Uploaded files appear in the tree at their destination without a manual refresh, and a folder
|
||||
upload recreates its directory structure.
|
||||
|
||||
## Verification
|
||||
|
||||
Write the menu-presence and guard assertions first and confirm they fail before the production
|
||||
change. Then:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- components/task/file-browser-toolbar.test.tsx components/task/file-context-menu.test.tsx
|
||||
cd apps/web && node ../node_modules/typescript/bin/tsc --noEmit && pnpm run lint && pnpm run i18n:check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task/file-browser-toolbar.tsx` and its test
|
||||
- `apps/web/components/task/file-context-menu.tsx` and its test
|
||||
- `apps/web/components/task/file-browser.tsx`
|
||||
- `apps/web/components/task/files-panel.tsx`
|
||||
- `apps/web/components/task/task-files-panel.tsx`
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 05.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements: `REQ-UI-WORKSPACE-FILE-TRANSFER-001`.
|
||||
- System design: `Components and responsibilities > Frontend`.
|
||||
- Plan: `Frontend > Upload entry points` and `Mobile design contract`.
|
||||
- Existing patterns: `WorkspaceActionsMenu` in the same toolbar file, and the download guard at
|
||||
`file-context-menu.tsx:114` for the inverse condition.
|
||||
|
||||
## Risks
|
||||
|
||||
- Inline file creation is currently one click on the `+` and becomes two through the menu. Keep
|
||||
`onStartCreate` semantics identical and cover the existing flow in the toolbar test, or this is a
|
||||
regression dressed as a feature.
|
||||
- The hidden inputs must be reset between selections, or picking the same file twice in a row fires
|
||||
no change event.
|
||||
- `webkitdirectory` is non-standard but universally supported in the browsers Kandev targets. Confirm
|
||||
the desktop Tauri webview honors it rather than assuming.
|
||||
- Touch targets must come from the existing sizing idiom rather than being restyled per surface.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report both entry points, the destination resolution rule, files changed, exact commands and results,
|
||||
then mark this task `done` and update its checkbox in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
- `file-browser-toolbar.tsx` gains `CreateMenu`: **New file / Upload files / Upload folder**. When no
|
||||
upload handler is supplied it falls back to the original one-click `ToolbarButton`, so surfaces
|
||||
without upload keep today's behavior exactly.
|
||||
- `file-context-menu.tsx` gains **Upload files here**, guarded as `node.is_dir && !isBulk`, the exact
|
||||
inverse of the download guard beside it.
|
||||
- `components/task/use-file-upload-entry-points.tsx` owns the two hidden inputs (one `multiple`, one
|
||||
`webkitdirectory`), the conflict dialog, and the result toasts. It is mounted in `FileBrowser`,
|
||||
which both panels already render, so neither panel needed changing and both behave identically.
|
||||
- The destination is captured when the picker opens, not when it resolves, because the active folder
|
||||
can change while the OS dialog is up. Inputs are reset before `.click()` so picking the same file
|
||||
twice in a row still fires a change event.
|
||||
- Ten `task:` keys across five locales plus pseudo.
|
||||
|
||||
**Refactors forced by the code-quality limits**, both mechanical:
|
||||
- `FileContextMenuItems` moved to a new `file-context-menu-items.tsx`; adding the upload item pushed
|
||||
`file-context-menu.tsx` to 605 lines and its complexity to 19.
|
||||
- The delete-descriptor construction became `useDeleteAction`, since `FileContextMenu` hit 103 lines
|
||||
and complexity 16.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
pnpm --filter @kandev/web test -- components/task/file-context-menu.test.tsx components/task/file-browser-toolbar.test.tsx components/task/file-upload-conflict-dialog.test.tsx components/task/file-browser 82 passed / 14 files
|
||||
node ../node_modules/typescript/bin/tsc --noEmit clean
|
||||
pnpm run i18n:check 6/6 gates
|
||||
pnpm run lint 0 problems
|
||||
```
|
||||
|
||||
A test asserts **New file still begins inline creation** through the menu, which is the regression
|
||||
the risk section called out.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
id: "07-file-transfer-e2e"
|
||||
title: "Workspace file transfer end-to-end coverage"
|
||||
status: in_progress
|
||||
wave: 6
|
||||
depends_on:
|
||||
- "02-editor-download-actions"
|
||||
- "06-upload-entry-points"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-001
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-002
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-004
|
||||
acceptance_criteria:
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-001.5
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.2
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-002.5
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.3
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.4
|
||||
- AC-UI-WORKSPACE-FILE-TRANSFER-004.5
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/workspace-file-transfer.md
|
||||
---
|
||||
|
||||
# Task 07: Workspace file transfer end-to-end coverage
|
||||
|
||||
## Summary
|
||||
|
||||
Prove both halves work through the real stack. The unit tests own the boundaries; this owns the
|
||||
integration evidence the user-facing requirements need.
|
||||
|
||||
## Scope
|
||||
|
||||
- A new `apps/web/e2e/tests/task/workspace-file-transfer.spec.ts` covering five scenarios: flat
|
||||
upload, conflict resolution, cancel, folder upload, and download from the unpreviewable-file
|
||||
screen.
|
||||
- `setInputFiles` against the hidden inputs rather than simulating an OS dialog.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- Any production change. If a scenario cannot pass, fix the owning work order rather than weakening
|
||||
the assertion.
|
||||
- Folder download.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Uploading files through the create menu makes them appear in the tree at the destination without a
|
||||
manual refresh, and uploading a folder recreates its structure at the right relative paths.
|
||||
- The conflict dialog lists exactly the conflicts, and each resolution behaves correctly: Replace
|
||||
overwrites, Keep both produces the `-1` name beside an untouched original, Skip writes nothing, and
|
||||
cancel writes nothing at all including the non-conflicting files.
|
||||
- The unpreviewable-file screen exposes the download control and downloads bytes matching the source.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:raw e2e/tests/task/workspace-file-transfer.spec.ts
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/task/workspace-file-transfer.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 02 and 06. Both halves must be rendered.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Requirements: the three user-facing requirements and their acceptance criteria.
|
||||
- Existing patterns: `apps/web/e2e/tests/` conventions, with `file-tree-multi-select.spec.ts` as the
|
||||
nearest neighbour for tree interaction.
|
||||
- `apps/web/e2e/README.md` for project selection and gating.
|
||||
|
||||
## Risks
|
||||
|
||||
- Asserting on a toast that auto-dismisses is flaky. Prefer asserting the resulting tree node, and
|
||||
treat the confirmation as a secondary check.
|
||||
- The cancel scenario is the one most worth getting right, because its assertion is an absence.
|
||||
Assert that the non-conflicting file is also absent, not just the conflicting one.
|
||||
- Binary download assertions need the Playwright download event and a byte comparison, not a
|
||||
file-name check.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the scenarios covered, files changed, exact commands and results, then
|
||||
mark this task `done` and update its checkbox in `plan.md`.
|
||||
|
||||
## Results
|
||||
|
||||
Spec written with five scenarios: flat upload, conflict resolution, cancel-writes-nothing, folder
|
||||
upload, and download from the unpreviewable screen. It typechecks, lints, and passes the e2e sleep
|
||||
ratchet.
|
||||
|
||||
**The spec has NOT been executed.** `pnpm e2e:raw -- <path>` does not forward the path filter, so two
|
||||
attempts silently ran the entire suite instead. Correct invocation is
|
||||
`pnpm e2e:raw e2e/tests/task/workspace-file-transfer.spec.ts` with no `--`. Running it also needs
|
||||
`make -C apps/backend e2e-plugin-package`, a current backend build, and `make build-web`.
|
||||
|
||||
**A real defect was found while capturing PR media and fixed here:** `setInputFiles` on a
|
||||
`webkitdirectory` input requires a directory path, not file descriptors. The folder scenario had
|
||||
passed a synthetic file named `bundle/nested/leaf.txt`, which would have failed on first run. It now
|
||||
builds a real directory on disk and points the input at it.
|
||||
|
||||
Every scenario this spec asserts was instead exercised manually against a live dev instance through
|
||||
the real UI, driven by Playwright (see the manual verification section in `plan.md`). That is
|
||||
evidence the feature works; it is not a substitute for the automated coverage, which still needs one
|
||||
green run.
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
status: draft
|
||||
system: ui
|
||||
created: 2026-09-01
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Workspace File Transfer Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The Files panel lets a person read and edit the task workspace, but it cannot move bytes across the
|
||||
browser boundary in either direction with any reliability. There is no upload at all: the workspace
|
||||
file contract exposes create, update-by-diff, rename, and delete, and none of them accept raw file
|
||||
content. Download exists but is reachable from exactly one place, a single-file right-click, so the
|
||||
surfaces where downloading is the only sensible action offer no way to do it.
|
||||
|
||||
That gap forces people out of the workbench. Getting a fixture, a screenshot, a certificate, or a
|
||||
vendor export into the workspace means a terminal, an agent instruction, or a commit. Getting a
|
||||
generated archive or PDF back out means the same trip in reverse, from a screen that has already
|
||||
told the user it cannot preview the file.
|
||||
|
||||
Chat attachments partly cover the inbound case, but they are a different tool: they are addressed to
|
||||
a turn rather than to the workspace, so they interrupt the conversation and are awkward when the
|
||||
file is remembered late. They also do not help someone running Kandev on a headless machine who
|
||||
needs to push a file from their laptop, which is the case that motivated this most strongly in
|
||||
review.
|
||||
|
||||
This system owns the contract because the durable outcome is a Files-panel capability: which
|
||||
surfaces offer transfer, what the person sees while it runs, and how a collision or failure is
|
||||
presented. The transport it uses (session-scoped agentctl HTTP, the workspace path-containment
|
||||
rules, and the attachment streaming pattern) belongs to adjacent systems and is consumed here, not
|
||||
redefined.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Transfer:** Moving file bytes between the browser and the task workspace in either direction.
|
||||
- **Destination folder:** The workspace-relative directory an upload targets. The workspace root is
|
||||
the empty path.
|
||||
- **Unpreviewable file:** A workspace file the editor cannot render as text or as an image, shown
|
||||
today on the binary viewer screen.
|
||||
- **Conflict:** A selected file whose destination path already exists in the workspace.
|
||||
- **Preflight:** The check that reports conflicts for a whole selection before any byte is uploaded.
|
||||
- **Resolution:** The choice a person makes for a conflicting file: replace, keep both, or skip.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-UI-WORKSPACE-FILE-TRANSFER-001: Manual upload into the task workspace
|
||||
|
||||
**Intent:** A person working in a task can put a file from their machine into the task workspace
|
||||
without leaving the workbench, using the same panel they already use to browse it.
|
||||
|
||||
**User story:** As a person working in a task, I want to upload a file into a chosen workspace
|
||||
folder, so that I can supply fixtures, exports, and assets without a terminal or an agent detour.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-001.1:** The Files panel's create control offers **New File**,
|
||||
**Upload Files**, and **Upload Folder**. Choosing New File begins the existing inline
|
||||
file-creation flow with unchanged behavior; the two upload choices open the operating system's
|
||||
file picker and folder picker respectively.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-001.2:** Right-clicking a folder in the file tree offers **Upload
|
||||
files here**, targeting that folder. The action is absent for files and absent when more than one
|
||||
node is selected.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-001.3:** The picker accepts more than one file at a time, and each
|
||||
selected file is uploaded to the destination folder. When no folder is the active target, the
|
||||
destination is the workspace root.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-001.4:** A person can upload a folder. Its directory structure is
|
||||
recreated under the destination, intermediate directories are created as needed, and each
|
||||
contained file lands at its corresponding relative path.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-001.5:** While an upload runs, the panel shows that it is in
|
||||
progress. On success, each uploaded file appears in the file tree at its destination without a
|
||||
manual refresh, and the confirmation names the path it was written to.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-001.6:** A failed upload leaves no partial or truncated file in
|
||||
the workspace, removes any optimistic tree entry, and reports why it failed.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-001.7:** Upload is offered only when the panel has an active task
|
||||
session. Without one, the actions are absent rather than present and failing.
|
||||
|
||||
### REQ-UI-WORKSPACE-FILE-TRANSFER-004: Conflicts are resolved before anything is written
|
||||
|
||||
**Intent:** A person decides what happens to each colliding file, and makes that decision knowing the
|
||||
full set of conflicts, before any byte reaches the workspace.
|
||||
|
||||
**User story:** As a person uploading files, I want to be told which files already exist and choose
|
||||
what to do, so that I never silently replace work or discover a surprise rename afterwards.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-004.1:** Before any content is uploaded, the selection's
|
||||
destination paths are checked against the workspace and every conflict is reported as one result.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-004.2:** When there is no conflict, the upload proceeds with no
|
||||
additional prompt.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-004.3:** When there is at least one conflict, the person is shown
|
||||
which files conflict and chooses **Replace**, **Keep both**, or **Skip** for them. A choice can be
|
||||
applied to every remaining conflict at once.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-004.4:** **Keep both** writes the incoming file under the next
|
||||
available `name-<n>.ext` variant and leaves the existing file untouched. **Skip** writes nothing for that file. **Replace** overwrites it.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-004.5:** Cancelling the resolution uploads nothing at all, not
|
||||
even the files that had no conflict.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-004.6:** A resolution is honored per file. Uploading without a
|
||||
resolution for a conflicting path is refused rather than defaulting to overwrite, including when a
|
||||
conflicting path is created between the preflight and the upload.
|
||||
|
||||
### REQ-UI-WORKSPACE-FILE-TRANSFER-002: Download reachable from every file surface
|
||||
|
||||
**Intent:** Downloading a file is offered from the surfaces where a person is actually looking at
|
||||
that file, not only from a right-click in the tree.
|
||||
|
||||
**User story:** As a person viewing a workspace file, I want to download it from the screen I am
|
||||
already on, so that I do not have to hunt for the file again in the tree.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-002.1:** The file editor header offers a download action for the
|
||||
open file, in both the Monaco and CodeMirror editors, with the same placement, icon, and
|
||||
accessible name in each.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-002.2:** The unpreviewable-file screen offers a download action.
|
||||
Download is the primary useful action on that screen, which otherwise offers none.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-002.3:** The image viewer offers the same download action, since
|
||||
it shares the viewer header contract with the unpreviewable-file screen.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-002.4:** Downloading from a viewer uses the file content the
|
||||
viewer already holds and does not re-request it.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-002.5:** A downloaded file keeps its original bytes and its
|
||||
original file name. Content that is not text is not corrupted by the transfer.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-002.6:** The existing single-file download in the tree's
|
||||
right-click menu keeps its current behavior and placement.
|
||||
|
||||
### REQ-UI-WORKSPACE-FILE-TRANSFER-003: Safe and bounded transfer
|
||||
|
||||
**Intent:** Upload is the first path that writes caller-supplied bytes into a task workspace, so its
|
||||
containment, size, and concurrency behavior are part of the contract rather than an implementation
|
||||
detail.
|
||||
|
||||
**User story:** As an operator, I want workspace uploads to be bounded and contained, so that the
|
||||
feature cannot write outside the workspace, exhaust memory, or corrupt a file an agent is reading.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-003.1:** An upload destination that escapes the task workspace, by
|
||||
traversal, by absolute path, or through a symlinked directory, is rejected and writes nothing.
|
||||
This applies to every path segment supplied by a folder upload, not only the destination folder.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-003.2:** A single uploaded file larger than the message-attachment
|
||||
limit is rejected with a size-specific message, and the rejection is reported per file rather than
|
||||
failing the whole selection.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-003.3:** Upload requires the same authenticated identity as other
|
||||
workspace mutations. An unauthenticated request is refused.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-003.4:** An upload is never observable by the agent as a partially
|
||||
written file. The destination path either does not exist or holds the complete uploaded content.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-003.5:** Uploaded bytes are streamed end to end. No layer holds a
|
||||
whole file in memory or encodes it as base64.
|
||||
- **AC-UI-WORKSPACE-FILE-TRANSFER-003.6:** A completed upload emits the same workspace change
|
||||
notification as the other file mutations, so the file tree and Git status converge without a
|
||||
manual refresh.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Dragging files or folders from the operating system onto the file tree. The tree's existing
|
||||
internal drag-to-move behavior is unchanged, and no external-drop branch is added to it.
|
||||
- Downloading a folder, or downloading more than one selected file, as an archive.
|
||||
- Replacing the 10 MB workspace read cap, or the streaming download endpoint that would be needed to
|
||||
transfer a file above it.
|
||||
- The separate defect where a file above the read cap leaves the editor on its loading state
|
||||
indefinitely.
|
||||
- Uploading into anything other than the active task session's workspace.
|
||||
- Version history, restore, or any retention policy for replaced files.
|
||||
|
||||
## Related work
|
||||
|
||||
- Chat attachments already stream multipart from the browser through the backend into agentctl. That
|
||||
contract is owned by the task-attachment path and is the exemplar this capability follows; it is
|
||||
not modified here.
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
status: draft
|
||||
system: ui
|
||||
requirements:
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-001
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-002
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-003
|
||||
- REQ-UI-WORKSPACE-FILE-TRANSFER-004
|
||||
---
|
||||
|
||||
# Workspace File Transfer System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This system owns which Files-panel surfaces offer transfer, what the person sees while a transfer
|
||||
runs, and how collisions and failures are presented. It also owns the shape of the new upload
|
||||
contract, because no workspace contract accepts raw bytes today.
|
||||
|
||||
Adjacent contracts this design consumes but does not own:
|
||||
|
||||
- **Workspace path containment.** `WorkspaceTracker.resolveMutationPath` and the `os.Root` rules in
|
||||
`apps/backend/internal/agentctl/server/process/workspace_files.go` decide what a workspace-relative
|
||||
path may resolve to. Upload routes through them rather than joining paths itself.
|
||||
- **Attachment streaming.** The browser to backend to agentctl multipart pattern in
|
||||
`attachment_handlers.go`, `client_attachments.go`, and `server/api/attachments.go` is reused
|
||||
verbatim in structure. Its size constant is reused as the per-file limit.
|
||||
- **Session to execution resolution.** Reaching an agentctl client from a session id belongs to the
|
||||
lifecycle manager, as used by `RegisterProcessRoutes`.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-UI-WORKSPACE-FILE-TRANSFER-001` | [Components and responsibilities](#components-and-responsibilities), [Control flow](#control-flow) |
|
||||
| `REQ-UI-WORKSPACE-FILE-TRANSFER-002` | [Components and responsibilities](#components-and-responsibilities), [Control flow](#control-flow) |
|
||||
| `REQ-UI-WORKSPACE-FILE-TRANSFER-003` | [Security](#security), [Failure and recovery](#failure-and-recovery), [Persistence](#persistence) |
|
||||
| `REQ-UI-WORKSPACE-FILE-TRANSFER-004` | [Data and contracts](#data-and-contracts), [Control flow](#control-flow) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
### Backend
|
||||
|
||||
- **`WorkspaceTracker.WriteFileStream`** (new, in `server/process/workspace_files.go`) is the single
|
||||
write-bytes primitive. It resolves the destination through the existing `resolveMutationPath`,
|
||||
takes the existing `runWorkspaceMutationBarrier`, applies the caller's resolution, creates missing
|
||||
intermediate directories under the same rooted handle, writes to a temporary file in the
|
||||
destination directory, fsyncs, renames into place, and emits the standard change notification
|
||||
through `mutationNotificationPath`. It returns the final path and byte count.
|
||||
- **`WorkspaceTracker.CheckUploadConflicts`** (new, same file) resolves a list of candidate
|
||||
destination paths and reports which already exist. It performs the same containment resolution as
|
||||
the write, so an unwritable path is rejected at preflight rather than after the person has chosen a
|
||||
resolution.
|
||||
- **agentctl upload handlers** (new, `server/api/workspace_upload.go`) registered beside the existing
|
||||
workspace routes in `server.go`: `POST /api/v1/workspace/file/upload-preflight` taking a JSON path
|
||||
list and returning conflicts, and `POST /api/v1/workspace/file/upload` taking the multipart body.
|
||||
The upload handler bounds the request with `http.MaxBytesReader`, reads metadata with
|
||||
`multipart.Reader`, and streams the file part into `WriteFileStream`. It uses a bounded temporary
|
||||
file only when a caller sends the file part before its metadata. It owns the HTTP status mapping for
|
||||
oversize, containment, missing-resolution, and IO failures.
|
||||
- **`Client.UploadWorkspaceFile`** (new, `internal/agent/runtime/agentctl/client_workspace_upload.go`)
|
||||
mirrors `MaterializeAttachment`: an `io.Pipe`, a goroutine writing the multipart body, the
|
||||
long-running HTTP client, and a bounded error-body read.
|
||||
- **Workspace file HTTP handlers** (new, `internal/task/handlers/workspace_file_http_handlers.go`)
|
||||
expose `POST /api/v1/task-sessions/:id/workspace/files`. They authenticate through `authn.FromGin`,
|
||||
resolve the session to an agentctl client through the lifecycle manager, stage one bounded file part
|
||||
so its declared size is checked before forwarding, and stream the staged file into
|
||||
`UploadWorkspaceFile`. Registered next to `RegisterProcessRoutes`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **`lib/api/domains/workspace-file-api.ts`** (new) posts the `FormData`, sets
|
||||
`credentials: "include"` and the interim settings interlock header, and raises `ApiError` on
|
||||
failure. It models one API call per file so a per-file result is available.
|
||||
- **`hooks/use-file-upload.ts`** (new) owns the two-phase flow and per-file state, keyed by a
|
||||
client-side id, using the `pending | uploading | ready | failed` vocabulary already used by chat
|
||||
attachments, plus a `blocked` state for a conflict awaiting resolution.
|
||||
- **`components/task/file-upload-conflict-dialog.tsx`** (new) presents the conflict set and collects
|
||||
a resolution per file, with an apply-to-all control.
|
||||
- **`lib/utils/upload-selection.ts`** (new) flattens a browser selection into destination-relative
|
||||
paths. A picker folder selection carries `webkitRelativePath`; a flat multi-file pick yields bare
|
||||
names. Both normalize to the same `{ file, relativePath }` list so the hook and the dialog see one
|
||||
shape.
|
||||
- **`components/task/use-file-upload-entry-points.tsx`** owns upload state and mounts the same flow for
|
||||
both Files-panel entry points. File operations keep the existing CRUD and download responsibilities.
|
||||
- **`components/task/file-browser-toolbar.tsx`** turns the create control into a menu.
|
||||
- **`components/task/file-context-menu.tsx`** adds the folder upload item.
|
||||
- **`components/editors/monaco/monaco-editor-toolbar.tsx`** and
|
||||
**`components/editors/codemirror/codemirror-code-editor.tsx`** each gain a download control.
|
||||
- **`components/task/file-editor-panel.tsx`** extends the `headerActions` fragment it already passes
|
||||
to the binary and image viewers, which lights up both screens and the mobile viewer at once.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
### Preflight routes
|
||||
|
||||
The backend route is `POST /api/v1/task-sessions/:id/workspace/files/preflight`. It forwards JSON to
|
||||
the agentctl route `POST /api/v1/workspace/file/upload-preflight`.
|
||||
|
||||
```json
|
||||
{ "dir": "fixtures", "repo": "", "paths": ["a.json", "nested/b.json"] }
|
||||
```
|
||||
|
||||
Response `200`:
|
||||
|
||||
```json
|
||||
{ "conflicts": [{ "path": "fixtures/a.json", "is_dir": false }] }
|
||||
```
|
||||
|
||||
`paths` are destination-relative, so a folder upload sends its structure here and learns about every
|
||||
collision in one call. A path that fails containment is reported as an error rather than a conflict,
|
||||
so it never reaches the resolution dialog.
|
||||
|
||||
### Upload routes
|
||||
|
||||
The backend route is `POST /api/v1/task-sessions/:id/workspace/files`. It forwards a multipart form to
|
||||
the agentctl route `POST /api/v1/workspace/file/upload`.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `dir` | text | Workspace-relative destination directory. Empty means the workspace root. |
|
||||
| `relative_path` | text | Path of this file beneath `dir`, for a folder upload. A bare name for a flat upload. |
|
||||
| `repo` | text | Optional repository subpath for a multi-repository task. |
|
||||
| `size_bytes` | text | Declared size, validated against the received byte count. |
|
||||
| `resolution` | text | `replace`, `keep_both`, or absent when the preflight found no conflict. |
|
||||
| `file` | file | The content. Streamed, never buffered whole. |
|
||||
|
||||
Response `201`:
|
||||
|
||||
```json
|
||||
{ "path": "fixtures/a-1.json", "size_bytes": 20481, "resolution_applied": "keep_both" }
|
||||
```
|
||||
|
||||
A file resolved as **Skip** is never sent. If the destination exists and no `resolution` was
|
||||
supplied, the request is refused with `409` rather than overwriting, which is what makes
|
||||
`AC-UI-WORKSPACE-FILE-TRANSFER-004.6` hold even when the workspace changes between the two phases.
|
||||
|
||||
One request carries one file; a selection issues one request per file so that one rejection does not
|
||||
fail the rest, satisfying `AC-UI-WORKSPACE-FILE-TRANSFER-003.2`.
|
||||
|
||||
### Limits
|
||||
|
||||
The per-file cap is `models.MaxMessageAttachmentBytes`, the same constant chat attachments use. The
|
||||
agentctl request bound follows the existing precedent of that constant plus a multipart overhead
|
||||
allowance, as `maxMaterializedAttachmentRequestBytes` already does.
|
||||
|
||||
## Control flow
|
||||
|
||||
Upload is two-phase, so that no byte is written before the person has decided what happens to every
|
||||
conflict:
|
||||
|
||||
1. A Files-panel surface collects a selection and a destination folder. `upload-selection`
|
||||
normalizes it to `{ file, relativePath }`, flattening a picked folder through
|
||||
`webkitRelativePath`.
|
||||
2. `useFileUpload` posts the destination-relative path list to the preflight route.
|
||||
3. No conflicts: proceed straight to step 5.
|
||||
4. Conflicts: the dialog collects a resolution per file. Cancelling ends the flow having written
|
||||
nothing. **Skip** removes that file from the batch.
|
||||
5. Each remaining file is marked `uploading`, gets an optimistic tree node, and is posted as one
|
||||
multipart request carrying its `relative_path` and its `resolution`.
|
||||
6. The backend authenticates, resolves the session to an agentctl client, and streams the part into
|
||||
`UploadWorkspaceFile`, which pipes a fresh multipart body to agentctl.
|
||||
7. The agentctl handler streams into `WriteFileStream`, which resolves, barriers, creates missing
|
||||
intermediate directories, applies the resolution, writes to a temporary file, fsyncs, renames,
|
||||
and notifies.
|
||||
8. The response path replaces the optimistic node; a failure removes it and raises a toast.
|
||||
|
||||
The preflight is advisory, not a lock. It exists to let one person make an informed decision, and the
|
||||
`409` on an unresolved existing destination is what keeps the write itself safe if the workspace
|
||||
changed underneath it.
|
||||
|
||||
Download needs no new transport. The viewer already holds the content that
|
||||
`workspace.file.get` returned, so the button hands that content to the existing
|
||||
`triggerFileDownload`, which base64-decodes binary content and clicks an anchor. This is why
|
||||
`REQ-UI-WORKSPACE-FILE-TRANSFER-002` carries no backend work: `useFileLoader` fetches before either
|
||||
viewer renders and `resolveFileCategory` only chooses a viewer after that fetch resolves, so any
|
||||
file that can display a download button is already loaded and already under the read cap.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
- **Oversize.** Rejected at the backend edge with a size-specific status before the workspace is
|
||||
touched. Reported against the individual file.
|
||||
- **Containment rejection.** `resolveMutationPath` refuses; nothing is written; the response
|
||||
distinguishes this from an IO error.
|
||||
- **Transport failure mid-stream.** The temporary file is removed on any error path, so the
|
||||
destination is never left holding a truncated file.
|
||||
- **Conflict.** Not a failure, and not resolved by the server on its own. Preflight reports it, the
|
||||
person decides, and the write refuses with `409` if it arrives without a decision.
|
||||
- **Conflict appearing after preflight.** The `409` catches it. The UI re-runs the preflight for the
|
||||
remaining files rather than guessing.
|
||||
- **No active session.** The surfaces are not rendered, so there is no request to fail.
|
||||
- **Partial multi-file selection.** Each file reports independently; successful files stay.
|
||||
- **Unreadable entry in a picked folder.** That entry is reported and skipped; the rest of the
|
||||
selection still uploads. One bad entry never aborts the whole batch.
|
||||
|
||||
## Persistence
|
||||
|
||||
Uploaded content lands in the task workspace on disk and has no database representation. There is no
|
||||
migration, no retention policy, and no restart behavior: a file that finished writing is an ordinary
|
||||
workspace file from that moment on, indistinguishable from one the agent wrote. Collision handling is
|
||||
explicit: **Replace** can overwrite an existing file, while **Keep both** and **Skip** preserve it. The
|
||||
workspace has no undo record, so the UI keeps **Keep both** as the safe default.
|
||||
|
||||
## Security
|
||||
|
||||
Upload is the first path that writes caller-supplied bytes into a workspace, so containment is the
|
||||
load-bearing control:
|
||||
|
||||
- Every destination resolves through `resolveMutationPath` and its `os.Root` handle. The handler
|
||||
never joins paths itself, so traversal, absolute paths, and symlinked directories are refused by
|
||||
the same code that already guards create, rename, and delete.
|
||||
- Folder upload widens the attack surface, because the client supplies interior path segments rather
|
||||
than a single file name. Every segment of a `relative_path` is resolved through the same
|
||||
rooted handle, and intermediate directory creation happens through that handle too, so a crafted
|
||||
`relative_path` cannot escape by traversal or by landing on an existing symlink.
|
||||
- The backend route requires an authenticated identity through `authn.FromGin`, matching the
|
||||
attachment upload guard.
|
||||
- Request bodies are bounded by `http.MaxBytesReader` before parsing, so an oversize body cannot be
|
||||
used to exhaust memory.
|
||||
- Content is streamed through `io.Pipe` and `io.Copy` at every network hop. Metadata and error bodies
|
||||
use bounded reads, and no layer base64-encodes file content. This keeps memory bounded for large
|
||||
files.
|
||||
|
||||
This capability does not widen the workspace trust boundary. The task workspace is already fully
|
||||
writable by the agent running in it, so upload adds a new route to a capability that exists, under
|
||||
authentication the agent path does not require. The marginal risk is therefore containment
|
||||
correctness rather than a new class of access, which is why the write is expressed as a thin
|
||||
addition to the existing mutation primitives rather than a new subsystem behind a rollout toggle.
|
||||
|
||||
## Observability
|
||||
|
||||
Failed uploads log at the agentctl and backend boundaries with the workspace-relative path and the
|
||||
failure class, following the existing workspace mutation handlers. Successful uploads are not
|
||||
logged individually, matching the surrounding file mutations. No new metric is introduced; the
|
||||
existing workspace change notification is the signal that a write landed.
|
||||
|
||||
## Related decisions
|
||||
|
||||
No new architecture decision record is required. The transport reuses the attachment streaming
|
||||
pattern, and the write reuses the workspace mutation primitives; neither establishes a new boundary.
|
||||
Reference in New Issue
Block a user