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:
Corey Fogg
2026-09-03 08:07:13 +00:00
committed by GitHub
parent a170366e34
commit d4742089fc
77 changed files with 7113 additions and 278 deletions
@@ -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
}
+2 -1
View File
@@ -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}
+11 -1
View File
@@ -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}
+18
View File
@@ -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();
});
});
+65 -76
View File
@@ -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(
+193 -67
View File
@@ -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");
});
});
+290
View File
@@ -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);
});
});
+388
View File
@@ -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");
});
});
+69
View File
@@ -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;
}
+2 -1
View File
@@ -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"
}
+18 -1
View 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"
}
+2 -1
View File
@@ -87,5 +87,6 @@
"toggleCode": "Ţōĝĝĺē ćōďē",
"toggleWordWrap": "Ţōĝĝĺē ŵōŕď ŵŕàƥ",
"underlineCmdU": "Ũńďēŕĺĩńē (Ćḿď+Ũ)",
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ"
"failedToOpenEditor": "Ƒàĩĺēď ţō ōƥēń ēďĩţōŕ",
"downloadFile": "Ďōŵńĺōàď ƒĩĺē"
}
+18 -1
View File
@@ -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}} ƒĩĺēś ƒàĩĺēď ţō ũƥĺōàď"
}
+2 -1
View File
@@ -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"
}
+18 -1
View File
@@ -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"
}
+2 -1
View File
@@ -87,5 +87,6 @@
"toggleCode": "切换代码",
"toggleWordWrap": "切换自动换行",
"underlineCmdU": "下划线(Cmd+U)",
"failedToOpenEditor": "打开编辑器失败"
"failedToOpenEditor": "打开编辑器失败",
"downloadFile": "下载文件"
}
+18 -1
View File
@@ -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}} 个文件上传失败"
}
+2 -1
View File
@@ -87,5 +87,6 @@
"toggleCode": "切換程式碼",
"toggleWordWrap": "切換自動換行",
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗"
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案"
}
+19 -2
View File
@@ -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}} 個檔案上載失敗"
}
+2 -1
View File
@@ -87,5 +87,6 @@
"toggleCode": "切換程式碼",
"toggleWordWrap": "切換自動換行",
"underlineCmdU": "下劃線(Cmd+U)",
"failedToOpenEditor": "開啟編輯器失敗"
"failedToOpenEditor": "開啟編輯器失敗",
"downloadFile": "下載檔案"
}
+19 -2
View File
@@ -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}} 個檔案上傳失敗"
}
+363
View File
@@ -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.