diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index b75bc2addd..0ecdcfb12f 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -4,6 +4,8 @@ import ( "bufio" "encoding/json" "fmt" + "os" + "os/exec" "strings" ) @@ -26,8 +28,10 @@ func emitPredefinedScenario(enc *json.Encoder, scanner *bufio.Scanner, name stri scenarioAllTools(enc, scanner) case "multi-turn": scenarioMultiTurn(enc) + case "diff-expansion-setup": + scenarioDiffExpansionSetup(enc) default: - emitTextBlock(enc, "Unknown e2e scenario: "+name+". Available: simple-message, read-and-edit, permission-flow, error, subagent, all-tools, multi-turn", "") + emitTextBlock(enc, "Unknown e2e scenario: "+name+". Available: simple-message, read-and-edit, permission-flow, error, subagent, all-tools, multi-turn, diff-expansion-setup", "") } } @@ -346,3 +350,96 @@ func scenarioMultiTurn(enc *json.Encoder) { fixedDelay(50) emitTextBlock(enc, "Multi-turn response ready. Send another message to continue.", "") } + +// scenarioDiffExpansionSetup: creates a committed file and then modifies it, +// leaving an uncommitted diff that the UI can display with expansion enabled. +// +// Uses 200 lines with hunks at lines 50 (mid-top) and 150 (mid-bottom), so the +// diff viewer collapses ~90 lines between hunks and ~45 lines at each end. +// This gives three separate expand handles for the e2e test to interact with. +func scenarioDiffExpansionSetup(enc *json.Encoder) { + fixedDelay(50) + + wd, err := os.Getwd() + if err != nil { + emitTextBlock(enc, "diff-expansion-setup: getwd failed: "+err.Error(), "") + return + } + + // Build a 200-line file with distinct content per line so assertions can + // target specific line text after expanding collapsed regions. + const totalLines = 200 + originalLines := make([]string, totalLines) + for i := 0; i < totalLines; i++ { + originalLines[i] = fmt.Sprintf("func original_%03d() { /* line %d */ }", i+1, i+1) + } + original := strings.Join(originalLines, "\n") + "\n" + + filePath := "expansion_test.go" + if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil { + emitTextBlock(enc, "diff-expansion-setup: write failed: "+err.Error(), "") + return + } + + gitEnv := append(os.Environ(), + "GIT_AUTHOR_NAME=Mock Agent", + "GIT_AUTHOR_EMAIL=mock@test.local", + "GIT_COMMITTER_NAME=Mock Agent", + "GIT_COMMITTER_EMAIL=mock@test.local", + ) + + runGitCmd := func(args ...string) error { + cmd := exec.Command("git", append([]string{ + "-c", "commit.gpgsign=false", + "-c", "tag.gpgsign=false", + }, args...)...) + cmd.Dir = wd + cmd.Env = gitEnv + out, cmdErr := cmd.CombinedOutput() + if cmdErr != nil { + fmt.Fprintf(os.Stderr, "mock-agent: git %v failed: %v\nOutput: %s\n", args, cmdErr, out) + } + return cmdErr + } + + // Clean up from any previous test run so the add+commit is never a no-op. + // git rm --force removes the file from both the index and worktree; + // the commit records the deletion. Errors are ignored (file may not exist). + _ = runGitCmd("rm", "--force", filePath) + _ = runGitCmd("commit", "-m", "cleanup expansion_test.go") + + // Re-write the original content (git rm deleted it from the worktree). + if err := os.WriteFile(filePath, []byte(original), 0o644); err != nil { + emitTextBlock(enc, "diff-expansion-setup: re-write failed: "+err.Error(), "") + return + } + + if err := runGitCmd("add", filePath); err != nil { + emitTextBlock(enc, "diff-expansion-setup: git add failed", "") + return + } + if err := runGitCmd("commit", "-m", "add expansion_test.go for e2e diff expansion test"); err != nil { + emitTextBlock(enc, "diff-expansion-setup: git commit failed", "") + return + } + + // Modify line 50 (mid-top, index 49) and line 150 (mid-bottom, index 149). + // With 3 lines of context per hunk, the diff collapses: + // - lines 1-46 (~46 lines) above the first hunk + // - lines 54-146 (~93 lines) between the two hunks + // - lines 154-200 (~47 lines) below the second hunk + // Each collapsed region gets its own expand handle in the diff viewer. + modifiedLines := make([]string, totalLines) + copy(modifiedLines, originalLines) + modifiedLines[49] = "func modified_mid_top() { /* HUNK_TOP - modified line 50 */ }" + modifiedLines[149] = "func modified_mid_bottom() { /* HUNK_BOTTOM - modified line 150 */ }" + modified := strings.Join(modifiedLines, "\n") + "\n" + + if err := os.WriteFile(filePath, []byte(modified), 0o644); err != nil { + emitTextBlock(enc, "diff-expansion-setup: write modified failed: "+err.Error(), "") + return + } + + fixedDelay(100) + emitTextBlock(enc, "diff-expansion-setup complete: expansion_test.go has two-hunk uncommitted diff (lines 50 and 150 of 200).", "") +} diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 4e9787e6c5..e2e39b579d 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "strings" "github.com/kandev/kandev/internal/agent/lifecycle" agentctl "github.com/kandev/kandev/internal/agentctl/client" @@ -29,6 +30,7 @@ func NewWorkspaceFileHandlers(lm *lifecycle.Manager, log *logger.Logger) *Worksp func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorkspaceFileTreeGet, h.wsGetFileTree) d.RegisterFunc(ws.ActionWorkspaceFileContentGet, h.wsGetFileContent) + d.RegisterFunc(ws.ActionWorkspaceFileContentGetRef, h.wsGetFileContentAtRef) d.RegisterFunc(ws.ActionWorkspaceFileContentUpdate, h.wsUpdateFileContent) d.RegisterFunc(ws.ActionWorkspaceFileCreate, h.wsCreateFile) d.RegisterFunc(ws.ActionWorkspaceFileDelete, h.wsDeleteFile) @@ -113,6 +115,56 @@ func (h *WorkspaceFileHandlers) wsGetFileContent(ctx context.Context, msg *ws.Me return ws.NewResponse(msg.ID, msg.Action, response) } +// wsGetFileContentAtRef handles workspace.file.get_at_ref action +func (h *WorkspaceFileHandlers) wsGetFileContentAtRef(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + SessionID string `json:"session_id"` + Path string `json:"path"` + Ref string `json:"ref"` + } + + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if req.Path == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) + } + if req.Ref == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "ref is required", nil) + } + + // Get agent execution for this session + execution, found := h.lifecycle.GetExecutionBySessionID(req.SessionID) + if !found { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session", nil) + } + + // Get agentctl client + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) + } + + // Request file content at ref from agentctl + response, err := client.RequestFileContentAtRef(ctx, req.Path, req.Ref) + if err != nil { + // "File not found at ref" is expected for new files - log as debug, not error + errMsg := err.Error() + if strings.Contains(errMsg, "file not found at ref") { + h.logger.Debug("file not found at ref (expected for new files)", zap.String("session_id", req.SessionID), zap.String("path", req.Path), zap.String("ref", req.Ref)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, errMsg, nil) + } + h.logger.Error("failed to get file content at ref", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path), zap.String("ref", req.Ref)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to get file content at ref: %v", err), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, response) +} + // wsUpdateFileContent handles workspace.file.update action func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { diff --git a/apps/backend/internal/agentctl/client/client_files.go b/apps/backend/internal/agentctl/client/client_files.go index d0be542c17..6afedd14b1 100644 --- a/apps/backend/internal/agentctl/client/client_files.go +++ b/apps/backend/internal/agentctl/client/client_files.go @@ -110,6 +110,37 @@ func (c *Client) RequestFileContent(ctx context.Context, path string) (*FileCont return &response, nil } +// RequestFileContentAtRef requests file content at a specific git ref via HTTP GET +func (c *Client) RequestFileContentAtRef(ctx context.Context, path string, ref string) (*FileContentResponse, error) { + reqURL := fmt.Sprintf("%s/api/v1/workspace/file/content-at-ref?path=%s&ref=%s", c.baseURL, url.QueryEscape(path), url.QueryEscape(ref)) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request file content at ref: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file content at ref response body", zap.Error(err)) + } + }() + + var response FileContentResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file content at ref error: %s", response.Error) + } + + return &response, nil +} + // ApplyFileDiff applies a unified diff to a file via HTTP POST func (c *Client) ApplyFileDiff(ctx context.Context, path string, diff string, originalHash string) (*streams.FileUpdateResponse, error) { reqBody := streams.FileUpdateRequest{ diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 48a990bcee..63d97816ee 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -85,6 +85,7 @@ func (s *Server) setupRoutes() { // Workspace file operations (simple HTTP) api.GET("/workspace/tree", s.handleFileTree) api.GET("/workspace/file/content", s.handleFileContent) + api.GET("/workspace/file/content-at-ref", s.handleFileContentAtRef) api.POST("/workspace/file/content", s.handleFileUpdate) api.POST("/workspace/file/create", s.handleFileCreate) api.POST("/workspace/file/rename", s.handleFileRename) diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index b138f1de39..c68b9337ac 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -125,6 +125,29 @@ func (s *Server) handleFileContent(c *gin.Context) { c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary}) } +// handleFileContentAtRef handles file content requests at a specific git ref via HTTP GET +func (s *Server) handleFileContentAtRef(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(400, types.FileContentResponse{Error: "path is required"}) + return + } + + ref := c.Query("ref") + if ref == "" { + c.JSON(400, types.FileContentResponse{Error: "ref is required"}) + return + } + + content, size, isBinary, err := s.procMgr.GetWorkspaceTracker().GetFileContentAtRef(c.Request.Context(), path, ref) + if err != nil { + c.JSON(400, types.FileContentResponse{Path: path, Error: err.Error(), Size: size}) + return + } + + c.JSON(200, types.FileContentResponse{Path: path, Content: content, Size: size, IsBinary: isBinary}) +} + // handleFileUpdate handles file update requests via HTTP POST func (s *Server) handleFileUpdate(c *gin.Context) { var req streams.FileUpdateRequest diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 97057fd703..35e69b62f4 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "time" "unicode/utf8" @@ -21,6 +22,8 @@ import ( "go.uber.org/zap" ) +const maxFileSize = 10 * 1024 * 1024 // 10MB + // updateFiles updates the file listing func (wt *WorkspaceTracker) updateFiles(ctx context.Context) { files, err := wt.getFileList(ctx) @@ -251,8 +254,7 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, bool, return "", 0, false, fmt.Errorf("path is a directory, not a file") } - // Check file size (limit to 10MB) - const maxFileSize = 10 * 1024 * 1024 + // Check file size if info.Size() > maxFileSize { return "", info.Size(), false, fmt.Errorf("file too large (max 10MB)") } @@ -563,6 +565,62 @@ type scoredMatch struct { score int } +// GetFileContentAtRef returns the content of a file at a specific git ref (branch, commit, HEAD, etc). +// If the file is not valid UTF-8, it is base64-encoded and isBinary is true. +func (wt *WorkspaceTracker) GetFileContentAtRef(ctx context.Context, reqPath string, ref string) (string, int64, bool, error) { + // Validate path to prevent directory traversal + cleanPath := filepath.Clean(reqPath) + if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) { + return "", 0, false, fmt.Errorf("invalid path: path traversal detected") + } + + // Use git show to get the file content at the specified ref + // Format: git show : + gitRef := fmt.Sprintf("%s:%s", ref, cleanPath) + + // Preflight: check blob size before materializing content to avoid memory spikes. + sizeCmd := exec.CommandContext(ctx, "git", "cat-file", "-s", gitRef) + sizeCmd.Dir = wt.workDir + sizeOut, err := sizeCmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + if strings.Contains(stderr, "does not exist") || + strings.Contains(stderr, "Not a valid object") || + strings.Contains(stderr, "fatal: path") || + strings.Contains(stderr, "fatal: not a valid") { + return "", 0, false, fmt.Errorf("file not found at ref %s: %s", ref, cleanPath) + } + } + return "", 0, false, fmt.Errorf("failed to stat file at ref: %w", err) + } + blobSize, err := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) + if err != nil { + return "", 0, false, fmt.Errorf("failed to parse file size at ref: %w", err) + } + if blobSize > maxFileSize { + return "", blobSize, false, fmt.Errorf("file too large (max 10MB)") + } + + cmd := exec.CommandContext(ctx, "git", "show", gitRef) + cmd.Dir = wt.workDir + + content, err := cmd.Output() + if err != nil { + return "", 0, false, fmt.Errorf("failed to get file at ref: %w", err) + } + + size := int64(len(content)) + + // Detect binary: if content is not valid UTF-8, base64-encode it + if !utf8.Valid(content) { + encoded := base64.StdEncoding.EncodeToString(content) + return encoded, size, true, nil + } + + return string(content), size, false, nil +} + // SearchFiles searches for files matching the query string. // It uses fuzzy matching with scoring based on how well the query matches. func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go index dbbb1710bc..10f0af1aa6 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_diff.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_diff.go @@ -12,14 +12,11 @@ import ( // enrichWithDiffData adds diff information (additions, deletions, diff content) to file info func (wt *WorkspaceTracker) enrichWithDiffData(ctx context.Context, update *types.GitStatusUpdate) { - // Use remote branch if available, otherwise use HEAD - baseRef := "HEAD" - if update.RemoteBranch != "" { - baseRef = update.RemoteBranch - } - - wt.enrichWithUnstagedDiff(ctx, update, baseRef) - wt.enrichWithStagedDiff(ctx, update, baseRef) + // Always diff against HEAD for unstaged/staged content so that files committed + // locally (but not yet pushed) show only their uncommitted changes rather than + // the entire file as new. The remote branch is only relevant for ahead/behind counts. + wt.enrichWithUnstagedDiff(ctx, update, "HEAD") + wt.enrichWithStagedDiff(ctx, update, "HEAD") wt.enrichUntrackedFileDiffs(ctx, update) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go index bf245bc2a0..24aab78aee 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go @@ -750,6 +750,118 @@ func TestApplyFileDiff_ConflictDetection(t *testing.T) { } } +func TestGetFileContentAtRef(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + ctx := context.Background() + + // Commit a text file at HEAD so we can reference it by ref + writeFile(t, repoDir, "hello.txt", "line1\nline2\nline3\n") + runGit(t, repoDir, "add", "hello.txt") + runGit(t, repoDir, "commit", "-m", "add hello.txt") + + t.Run("existing file at HEAD returns content", func(t *testing.T) { + content, size, isBinary, err := wt.GetFileContentAtRef(ctx, "hello.txt", "HEAD") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if isBinary { + t.Error("expected isBinary=false for text file") + } + if content != "line1\nline2\nline3\n" { + t.Errorf("unexpected content: %q", content) + } + if size != int64(len("line1\nline2\nline3\n")) { + t.Errorf("unexpected size: %d", size) + } + }) + + t.Run("file not found at ref returns sentinel error", func(t *testing.T) { + _, _, _, err := wt.GetFileContentAtRef(ctx, "nonexistent.txt", "HEAD") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + if !strings.Contains(err.Error(), "file not found at ref") { + t.Errorf("expected 'file not found at ref' error, got: %v", err) + } + }) + + t.Run("binary file is base64 encoded", func(t *testing.T) { + binaryContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0xFE} + if err := os.WriteFile(filepath.Join(repoDir, "image.png"), binaryContent, 0o644); err != nil { + t.Fatalf("failed to write binary file: %v", err) + } + runGit(t, repoDir, "add", "image.png") + runGit(t, repoDir, "commit", "-m", "add binary file") + + content, _, isBinary, err := wt.GetFileContentAtRef(ctx, "image.png", "HEAD") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !isBinary { + t.Error("expected isBinary=true for binary file") + } + decoded, decErr := base64.StdEncoding.DecodeString(content) + if decErr != nil { + t.Fatalf("failed to decode base64: %v", decErr) + } + if string(decoded) != string(binaryContent) { + t.Error("decoded binary content does not match original") + } + }) + + t.Run("path traversal rejected", func(t *testing.T) { + _, _, _, err := wt.GetFileContentAtRef(ctx, "../../etc/passwd", "HEAD") + if err == nil { + t.Fatal("expected error for path traversal, got nil") + } + if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } + }) + + t.Run("absolute path rejected", func(t *testing.T) { + _, _, _, err := wt.GetFileContentAtRef(ctx, "/etc/passwd", "HEAD") + if err == nil { + t.Fatal("expected error for absolute path, got nil") + } + if !strings.Contains(err.Error(), "path traversal") { + t.Errorf("expected path traversal error, got: %v", err) + } + }) + + t.Run("file exists in older commit but deleted at HEAD", func(t *testing.T) { + writeFile(t, repoDir, "temporary.txt", "I will be deleted\n") + runGit(t, repoDir, "add", "temporary.txt") + runGit(t, repoDir, "commit", "-m", "add temporary.txt") + oldRef := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + + runGit(t, repoDir, "rm", "temporary.txt") + runGit(t, repoDir, "commit", "-m", "delete temporary.txt") + + // File should be found at the old commit + content, _, _, err := wt.GetFileContentAtRef(ctx, "temporary.txt", oldRef) + if err != nil { + t.Fatalf("expected content at old ref, got error: %v", err) + } + if content != "I will be deleted\n" { + t.Errorf("unexpected content: %q", content) + } + + // File should NOT be found at HEAD + _, _, _, err = wt.GetFileContentAtRef(ctx, "temporary.txt", "HEAD") + if err == nil { + t.Fatal("expected error for deleted file at HEAD, got nil") + } + if !strings.Contains(err.Error(), "file not found at ref") { + t.Errorf("expected 'file not found at ref' error, got: %v", err) + } + }) +} + func TestEmitFileChanges(t *testing.T) { t.Run("zero changes sends refresh", func(t *testing.T) { _, wt := setupTestDir(t) diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 9f159e5449..8e72e89842 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -195,6 +195,7 @@ const ( // Workspace file operations ActionWorkspaceFileTreeGet = "workspace.tree.get" ActionWorkspaceFileContentGet = "workspace.file.get" + ActionWorkspaceFileContentGetRef = "workspace.file.get_at_ref" ActionWorkspaceFileContentUpdate = "workspace.file.update" ActionWorkspaceFileCreate = "workspace.file.create" ActionWorkspaceFileDelete = "workspace.file.delete" diff --git a/apps/web/components/diff/diff-header-toolbar.tsx b/apps/web/components/diff/diff-header-toolbar.tsx index 5ea47bb2d0..b6bd7e593d 100644 --- a/apps/web/components/diff/diff-header-toolbar.tsx +++ b/apps/web/components/diff/diff-header-toolbar.tsx @@ -11,10 +11,43 @@ import { IconLayoutColumns, IconPencil, IconArrowBackUp, + IconFoldDown, + IconFold, } from "@tabler/icons-react"; import type { RenderHeaderMetadataProps } from "@pierre/diffs"; import type { ViewMode } from "@/hooks/use-global-view-mode"; +const iconBtn = "h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"; + +function ToolbarBtn({ + onClick, + tooltip, + className, + children, +}: { + onClick: () => void; + tooltip: string; + className?: string; + children: ReactNode; +}) { + return ( + + + + + {tooltip} + + ); +} + interface DiffHeaderToolbarOptions { filePath: string; diff?: string; @@ -24,111 +57,124 @@ interface DiffHeaderToolbarOptions { onToggleViewMode: () => void; onOpenFile?: (filePath: string) => void; onRevert?: (filePath: string) => void; + expandUnchanged?: boolean; + onToggleExpandUnchanged?: () => void; } -export function useDiffHeaderToolbar({ - filePath, - diff, +type ToolbarButtonsProps = Omit & { + resolvedPath: string; + onCopyDiff: () => void; +}; + +function DiffHeaderToolbarButtons({ + resolvedPath, + onCopyDiff, + onRevert, + expandUnchanged, + onToggleExpandUnchanged, wordWrap, onToggleWordWrap, viewMode, onToggleViewMode, onOpenFile, - onRevert, -}: DiffHeaderToolbarOptions) { - const renderHeaderMetadata = useCallback( +}: ToolbarButtonsProps) { + return ( +
+ + + + + {onRevert && ( + onRevert(resolvedPath)} tooltip="Revert changes"> + + + )} + + {onToggleExpandUnchanged && ( + + {expandUnchanged ? ( + + ) : ( + + )} + + )} + + + + + + + {viewMode === "split" ? ( + + ) : ( + + )} + + + {onOpenFile && ( + onOpenFile(resolvedPath)} tooltip="Edit"> + + + )} +
+ ); +} + +export function useDiffHeaderToolbar(opts: DiffHeaderToolbarOptions) { + const { + filePath, + diff, + wordWrap, + onToggleWordWrap, + viewMode, + onToggleViewMode, + onOpenFile, + onRevert, + expandUnchanged, + onToggleExpandUnchanged, + } = opts; + + return useCallback( (props: RenderHeaderMetadataProps): ReactNode => { const resolvedPath = props.fileDiff?.name || filePath; - return ( -
- - - - - Copy diff - - - {onRevert && ( - - - - - Revert changes - - )} - - - - - - Toggle word wrap - - - - - - - - {viewMode === "split" ? "Switch to unified view" : "Switch to split view"} - - - - {onOpenFile && ( - - - - - Edit - - )} -
+ navigator.clipboard.writeText(diff || "")} + wordWrap={wordWrap} + onToggleWordWrap={onToggleWordWrap} + viewMode={viewMode} + onToggleViewMode={onToggleViewMode} + onOpenFile={onOpenFile} + onRevert={onRevert} + expandUnchanged={expandUnchanged} + onToggleExpandUnchanged={onToggleExpandUnchanged} + /> ); }, - [filePath, diff, wordWrap, onToggleWordWrap, viewMode, onToggleViewMode, onOpenFile, onRevert], + [ + filePath, + diff, + wordWrap, + onToggleWordWrap, + viewMode, + onToggleViewMode, + onOpenFile, + onRevert, + expandUnchanged, + onToggleExpandUnchanged, + ], ); - - return renderHeaderMetadata; } diff --git a/apps/web/components/diff/diff-viewer-resolver.tsx b/apps/web/components/diff/diff-viewer-resolver.tsx index bf96e7c010..499114a746 100644 --- a/apps/web/components/diff/diff-viewer-resolver.tsx +++ b/apps/web/components/diff/diff-viewer-resolver.tsx @@ -28,13 +28,18 @@ interface DiffViewerResolverProps { wordWrap?: boolean; editable?: boolean; onModifiedContentChange?: (filePath: string, content: string) => void; + /** Enable diff expansion (pierre-diffs only - Monaco has built-in expansion) */ + enableExpansion?: boolean; + /** Base git ref for fetching old content (pierre-diffs only) */ + baseRef?: string; } export const DiffViewerResolved = memo(function DiffViewerResolved(props: DiffViewerResolverProps) { const provider = useEditorProvider("diff-viewer"); if (provider === "monaco") { + // Strip pierre-diffs-only props // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { enableComments, ...rest } = props; + const { enableComments, baseRef, enableExpansion, ...rest } = props; return ; } return ; diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index 910f8f1358..48b6727577 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, memo } from "react"; +import { useState, useRef, useCallback, memo, useEffect } from "react"; import { FileDiff } from "@pierre/diffs/react"; import { cn } from "@kandev/ui/lib/utils"; import type { FileDiffData, DiffComment } from "@/lib/diff/types"; @@ -33,6 +33,10 @@ interface DiffViewerProps { enableAcceptReject?: boolean; onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise | void; wordWrap?: boolean; + /** Enable diff expansion (show expand up/down buttons at hunk separators) */ + enableExpansion?: boolean; + /** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */ + baseRef?: string; } const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ @@ -46,6 +50,8 @@ const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [ "enableAcceptReject", "onRevertBlock", "wordWrap", + "enableExpansion", + "baseRef", ]; const DATA_KEYS: (keyof FileDiffData)[] = ["filePath", "diff", "oldContent", "newContent"]; @@ -59,6 +65,30 @@ function areCommentsEqual( return prev.every((c, i) => c.id === next[i].id && c.text === next[i].text); } +/** Auto-load expansion content and return whether expansion can be used. */ +function useAutoLoadExpansion( + enableExpansion: boolean, + state: ReturnType, +): boolean { + const { isExpansionContentLoaded, isExpansionLoading, expansionError, loadExpansionContent } = + state; + useEffect(() => { + if (enableExpansion && !isExpansionContentLoaded && !isExpansionLoading && !expansionError) { + void loadExpansionContent(); + } + }, [ + enableExpansion, + isExpansionContentLoaded, + isExpansionLoading, + expansionError, + loadExpansionContent, + ]); + const hasValidData = !!( + state.fileDiffMetadata?.oldLines?.length && state.fileDiffMetadata?.newLines?.length + ); + return enableExpansion && isExpansionContentLoaded && hasValidData; +} + function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps): boolean { for (const key of DATA_KEYS) { if (prevProps.data[key] !== nextProps.data[key]) return false; @@ -84,9 +114,13 @@ export const DiffViewer = memo(function DiffViewer({ enableAcceptReject = false, onRevertBlock, wordWrap: wordWrapProp, + enableExpansion = false, + baseRef, }: DiffViewerProps) { const [wordWrapLocal, setWordWrap] = useState(false); const wordWrap = wordWrapProp ?? wordWrapLocal; + const [expandUnchanged, setExpandUnchanged] = useState(false); + const toggleExpandUnchanged = useCallback(() => setExpandUnchanged((v) => !v), []); const wrapperRef = useRef(null); const state = useDiffViewerState({ @@ -98,6 +132,8 @@ export const DiffViewer = memo(function DiffViewer({ onCommentDelete, externalComments, onRevertBlock, + enableExpansion, + baseRef, }); const { onLineEnter, onLineLeave, onButtonEnter, onButtonLeave } = useHunkHover({ @@ -119,6 +155,7 @@ export const DiffViewer = memo(function DiffViewer({ }); const showHeader = !hideHeader && !compact; + const canUseExpansion = useAutoLoadExpansion(enableExpansion, state); const { options, renderHeaderMetadata, renderHoverUtility } = useDiffOptions({ filePath: data.filePath, @@ -132,6 +169,9 @@ export const DiffViewer = memo(function DiffViewer({ onLineLeave, onOpenFile, onRevert, + enableExpansion: canUseExpansion, + expandUnchanged, + onToggleExpandUnchanged: canUseExpansion ? toggleExpandUnchanged : undefined, }); const controlledSelection = state.showCommentForm ? state.selectedLines : null; diff --git a/apps/web/components/diff/file-diff-viewer.tsx b/apps/web/components/diff/file-diff-viewer.tsx index bfeedaa759..1149cd475c 100644 --- a/apps/web/components/diff/file-diff-viewer.tsx +++ b/apps/web/components/diff/file-diff-viewer.tsx @@ -23,6 +23,10 @@ interface FileDiffViewerProps { enableAcceptReject?: boolean; onRevertBlock?: (filePath: string, info: RevertBlockInfo) => void; wordWrap?: boolean; + /** Enable diff expansion (show expand up/down buttons at hunk separators) */ + enableExpansion?: boolean; + /** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */ + baseRef?: string; } /** @@ -51,6 +55,8 @@ export const FileDiffViewer = memo(function FileDiffViewer({ enableAcceptReject, onRevertBlock, wordWrap, + enableExpansion, + baseRef, }: FileDiffViewerProps) { const data = useMemo(() => transformGitDiff(filePath, diff, status), [filePath, diff, status]); @@ -70,6 +76,8 @@ export const FileDiffViewer = memo(function FileDiffViewer({ enableAcceptReject={enableAcceptReject} onRevertBlock={onRevertBlock} wordWrap={wordWrap} + enableExpansion={enableExpansion} + baseRef={baseRef} /> ); }); diff --git a/apps/web/components/diff/use-diff-options.tsx b/apps/web/components/diff/use-diff-options.tsx index c97273a120..9ad59edf83 100644 --- a/apps/web/components/diff/use-diff-options.tsx +++ b/apps/web/components/diff/use-diff-options.tsx @@ -68,6 +68,14 @@ type UseDiffOptionsArgs = { onLineLeave: () => void; onOpenFile?: (filePath: string) => void; onRevert?: (filePath: string) => void; + /** Enable diff expansion (requires oldLines/newLines in metadata) */ + enableExpansion?: boolean; + /** Number of lines to expand per click (default: 20) */ + expansionLineCount?: number; + /** When true, show all lines (no separators) */ + expandUnchanged?: boolean; + /** Toggle callback for expand-all button */ + onToggleExpandUnchanged?: () => void; }; type UseDiffOptionsResult = { @@ -90,6 +98,10 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult { onLineLeave, onOpenFile, onRevert, + enableExpansion = false, + expansionLineCount = 20, + expandUnchanged, + onToggleExpandUnchanged, } = args; const { resolvedTheme } = useTheme(); @@ -111,6 +123,8 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult { onToggleViewMode: toggleViewMode, onOpenFile, onRevert, + expandUnchanged, + onToggleExpandUnchanged, }); const renderHoverUtility = useCallback((): ReactNode => { @@ -125,12 +139,14 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult { ); }, [enableComments]); - const options = useMemo>( - () => ({ + const options = useMemo>(() => { + return { diffStyle: globalViewMode, themeType: resolvedTheme === "dark" ? "dark" : "light", enableLineSelection: enableComments, - hunkSeparators: "simple", + // "line-info" shows expand buttons when oldLines/newLines are on metadata; + // "simple" is a plain divider without expand controls. + hunkSeparators: enableExpansion ? "line-info" : "simple", enableHoverUtility: enableComments, diffIndicators: "none", onLineSelectionEnd: handleLineSelectionEnd, @@ -139,18 +155,22 @@ export function useDiffOptions(args: UseDiffOptionsArgs): UseDiffOptionsResult { disableFileHeader: !showHeader, overflow: wordWrap ? "wrap" : "scroll", unsafeCSS: DIFF_UNSAFE_CSS, - }), - [ - globalViewMode, - resolvedTheme, - enableComments, - showHeader, - handleLineSelectionEnd, - wordWrap, - onLineEnter, - onLineLeave, - ], - ); + expansionLineCount, + expandUnchanged, + }; + }, [ + globalViewMode, + resolvedTheme, + enableComments, + showHeader, + handleLineSelectionEnd, + wordWrap, + onLineEnter, + onLineLeave, + enableExpansion, + expansionLineCount, + expandUnchanged, + ]); return { globalViewMode, diff --git a/apps/web/components/diff/use-diff-viewer-state.ts b/apps/web/components/diff/use-diff-viewer-state.ts index 5a9b6e2ae8..7a999efa2e 100644 --- a/apps/web/components/diff/use-diff-viewer-state.ts +++ b/apps/web/components/diff/use-diff-viewer-state.ts @@ -9,6 +9,7 @@ import type { FileDiffData, DiffComment } from "@/lib/diff/types"; import { buildDiffComment, useCommentActions } from "@/lib/diff/comment-utils"; import { useDiffComments } from "./use-diff-comments"; import { useDiffMetadata } from "./use-diff-metadata"; +import { useExpandableDiff } from "./use-expandable-diff"; import type { RevertBlockInfo } from "./diff-viewer"; import type { AnnotationMetadata } from "./use-diff-annotation-renderer"; @@ -130,6 +131,10 @@ type UseDiffViewerStateOpts = { onCommentDelete?: (commentId: string) => void; externalComments?: DiffComment[]; onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise | void; + /** Enable diff expansion (requires fetching full file content) */ + enableExpansion?: boolean; + /** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */ + baseRef?: string; }; function useDiffViewerAnnotations({ @@ -253,6 +258,22 @@ function useDiffViewerCommentHandlers({ return { handleLineSelectionEnd, handleCommentSubmit, handleCommentDelete, handleCommentUpdate }; } +function useRevertBlock( + filePath: string, + onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise | void, +) { + const revertInfoRef = useRef>(new Map()); + const handleRevertBlock = useCallback( + async (changeBlockId: string) => { + const info = revertInfoRef.current.get(changeBlockId); + if (!info) return; + await onRevertBlock?.(filePath, info); + }, + [filePath, onRevertBlock], + ); + return { revertInfoRef, handleRevertBlock }; +} + export function useDiffViewerState(opts: UseDiffViewerStateOpts) { const { data, @@ -263,6 +284,8 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { onCommentDelete, externalComments, onRevertBlock, + enableExpansion = false, + baseRef, } = opts; const [selectedLines, setSelectedLines] = useState(null); @@ -284,22 +307,27 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { }); const comments = externalComments || internalComments; - const fileDiffMetadata = useDiffMetadata(data); + const baseDiffMetadata = useDiffMetadata(data); - // Revert info per change block - const revertInfoRef = useRef>(new Map()); - const handleRevertBlock = useCallback( - async (changeBlockId: string) => { - const info = revertInfoRef.current.get(changeBlockId); - if (!info) return; - await onRevertBlock?.(data.filePath, info); - }, - [data.filePath, onRevertBlock], - ); + const { + metadata: fileDiffMetadata, + isContentLoaded: isExpansionContentLoaded, + isLoading: isExpansionLoading, + error: expansionError, + loadContent: loadExpansionContent, + canExpand, + } = useExpandableDiff({ + sessionId, + filePath: data.filePath, + baseRef, + fileDiffMetadata: baseDiffMetadata, + enableExpansion, + }); - // Change line map for hover detection + const { revertInfoRef, handleRevertBlock } = useRevertBlock(data.filePath, onRevertBlock); const changeLineMapRef = useRef>(new Map()); const hideTimeoutRef = useRef | null>(null); + const annotations = useDiffViewerAnnotations({ comments, editingCommentId, @@ -310,6 +338,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { changeLineMapRef, revertInfoRef, }); + const { handleLineSelectionEnd, handleCommentSubmit, handleCommentDelete, handleCommentUpdate } = useDiffViewerCommentHandlers({ selectedLines, @@ -344,5 +373,11 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { handleCommentUpdate, changeLineMapRef, hideTimeoutRef, + // Expansion state + isExpansionContentLoaded, + isExpansionLoading, + expansionError, + loadExpansionContent, + canExpand, }; } diff --git a/apps/web/components/diff/use-expandable-diff.ts b/apps/web/components/diff/use-expandable-diff.ts new file mode 100644 index 0000000000..874f8ed326 --- /dev/null +++ b/apps/web/components/diff/use-expandable-diff.ts @@ -0,0 +1,135 @@ +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import type { FileDiffMetadata } from "@pierre/diffs"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { requestFileContent, requestFileContentAtRef } from "@/lib/ws/workspace-files"; + +/** Must match @pierre/diffs SPLIT_WITH_NEWLINES — splits preserving trailing \n */ +const SPLIT_WITH_NEWLINES = /(?<=\n)/; + +type UseExpandableDiffOptions = { + sessionId: string | undefined; + filePath: string; + baseRef: string | undefined; + fileDiffMetadata: FileDiffMetadata | null; + enableExpansion?: boolean; +}; + +type UseExpandableDiffResult = { + metadata: FileDiffMetadata | null; + isContentLoaded: boolean; + isLoading: boolean; + error: string | null; + loadContent: () => Promise; + canExpand: boolean; +}; + +type WsClient = NonNullable>; + +/** Fetch old file content at a git ref. Returns empty string for new files. */ +async function fetchOldContent( + client: WsClient, + sessionId: string, + filePath: string, + baseRef: string, +): Promise { + const res = await requestFileContentAtRef(client, sessionId, filePath, baseRef); + if (res.is_binary) throw new Error("Cannot expand binary files"); + if (!res.error) return res.content; + if (res.error.includes("file not found at ref")) return ""; + throw new Error(res.error); +} + +/** Fetch new file content. Returns empty string for deleted files. */ +async function fetchNewContent(client: WsClient, sessionId: string, filePath: string) { + const res = await requestFileContent(client, sessionId, filePath); + if (res.is_binary) throw new Error("Cannot expand binary files"); + if (res.error && /not found|no such file/i.test(res.error)) return ""; + if (res.error) throw new Error(res.error); + return res.content; +} + +/** Fetch both old and new content, split into lines for @pierre/diffs expansion. */ +async function fetchExpansionLines( + sessionId: string, + filePath: string, + baseRef: string | undefined, +) { + const client = getWebSocketClient(); + if (!client) throw new Error("WebSocket client not available"); + const newContent = await fetchNewContent(client, sessionId, filePath); + const oldContent = baseRef ? await fetchOldContent(client, sessionId, filePath, baseRef) : ""; + return { + oldLines: oldContent.split(SPLIT_WITH_NEWLINES), + newLines: newContent.split(SPLIT_WITH_NEWLINES), + }; +} + +/** + * Hook for managing expandable diffs with lazy-loaded file content. + * + * The @pierre/diffs library requires `oldLines` and `newLines` in FileDiffMetadata + * for expansion to work. This hook fetches old/new content and merges it into the + * metadata. + */ +export function useExpandableDiff({ + sessionId, + filePath, + baseRef, + fileDiffMetadata, + enableExpansion = false, +}: UseExpandableDiffOptions): UseExpandableDiffResult { + const requestVersionRef = useRef(0); + const [loadedContent, setLoadedContent] = useState<{ + oldLines: string[]; + newLines: string[]; + } | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Reset cached content when inputs change so stale data is never rendered. + useEffect(() => { + requestVersionRef.current += 1; + setLoadedContent(null); + setError(null); + }, [sessionId, filePath, baseRef]); + + const loadContent = useCallback(async () => { + if (!sessionId || !enableExpansion || loadedContent || isLoading) return; + + const version = ++requestVersionRef.current; + setIsLoading(true); + setError(null); + + try { + const lines = await fetchExpansionLines(sessionId, filePath, baseRef); + if (version === requestVersionRef.current) setLoadedContent(lines); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to load file content"; + console.error("[useExpandableDiff]", msg); + if (version === requestVersionRef.current) setError(msg); + } finally { + setIsLoading(false); + } + }, [sessionId, filePath, baseRef, enableExpansion, loadedContent, isLoading]); + + const metadata = useMemo(() => { + if (!fileDiffMetadata) return null; + if (!loadedContent) return fileDiffMetadata; + return { + ...fileDiffMetadata, + oldLines: loadedContent.oldLines, + newLines: loadedContent.newLines, + }; + }, [fileDiffMetadata, loadedContent]); + + const isContentLoaded = loadedContent !== null; + + return { + metadata, + isContentLoaded, + isLoading, + error, + loadContent, + canExpand: enableExpansion && isContentLoaded && !error, + }; +} diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index 2df81aae6c..c4b992ff98 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -334,8 +334,9 @@ function FileDiffSection({ enableAcceptReject onRevertBlock={handleRevertBlock} sessionId={sessionId} - hideHeader wordWrap={wordWrap} + enableExpansion={true} + baseRef="HEAD" /> ) : ( diff --git a/apps/web/components/task/commit-detail-panel.tsx b/apps/web/components/task/commit-detail-panel.tsx index f14e7aab70..c756b2ac8f 100644 --- a/apps/web/components/task/commit-detail-panel.tsx +++ b/apps/web/components/task/commit-detail-panel.tsx @@ -126,7 +126,12 @@ const CommitDetailPanel = memo(function CommitDetailPanel({
{commit && }
- + ); @@ -168,16 +173,19 @@ function CommitFileList({ fileEntries, loading, onOpenFile, + baseRef, }: { fileEntries: [string, FileInfo][]; loading: boolean; onOpenFile: (path: string) => void; + baseRef: string; }) { if (fileEntries.length === 0 && !loading) { return (
No files in this commit
); } + return ( <> {fileEntries.map(([path, file]) => ( @@ -188,6 +196,8 @@ function CommitFileList({ diff={file.diff} status={file.status} onOpenFile={onOpenFile} + enableExpansion={true} + baseRef={baseRef} /> ) : (
diff --git a/apps/web/e2e/tests/diff-expansion.spec.ts b/apps/web/e2e/tests/diff-expansion.spec.ts new file mode 100644 index 0000000000..39b5c1024b --- /dev/null +++ b/apps/web/e2e/tests/diff-expansion.spec.ts @@ -0,0 +1,185 @@ +import { test, expect } from "../fixtures/test-base"; +import { SessionPage } from "../pages/session-page"; +import type { ApiClient } from "../helpers/api-client"; +import type { SeedData } from "../fixtures/test-base"; +import type { Page } from "@playwright/test"; + +/** + * Seed a task using the diff-expansion-setup mock scenario and navigate to + * its session page, waiting for the agent turn to complete. + * + * The scenario writes a 50-line file, commits it, then modifies two lines far + * apart (line 3 and line 48). The diff viewer will show two separate hunks + * with ~44 collapsed lines between them. + */ +async function seedExpansionTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Diff Expansion E2E", + seedData.agentProfileId, + { + description: "/e2e:diff-expansion-setup", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await testPage.goto(`/s/${task.session_id}`); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await expect( + session.chat.getByText("diff-expansion-setup complete", { exact: false }), + ).toBeVisible({ timeout: 45_000 }); + + return session; +} + +/** Click the Changes dockview tab. */ +async function openChangesTab(testPage: Page) { + const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" }); + await expect(changesTab).toBeVisible({ timeout: 10_000 }); + await changesTab.click(); +} + +/** Click the file row for expansion_test.go to open its diff view. */ +async function openExpansionFileDiff(testPage: Page) { + const fileRow = testPage + .locator("button, [role='button'], [class*='file']") + .filter({ hasText: "expansion_test.go" }) + .first(); + await expect(fileRow).toBeVisible({ timeout: 10_000 }); + await fileRow.click(); +} + +test.describe("Diff expansion — Pierre Diffs provider", () => { + test.describe.configure({ retries: 1, timeout: 120_000 }); + + test("renders Pierre Diffs viewer and shows both hunks", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedExpansionTask(testPage, apiClient, seedData); + await openChangesTab(testPage); + await openExpansionFileDiff(testPage); + + // Pierre Diffs renders a diffs-container custom element + await expect(testPage.locator("diffs-container")).toBeVisible({ timeout: 15_000 }); + + await expect(testPage.getByText("HUNK_TOP", { exact: false })).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByText("HUNK_BOTTOM", { exact: false })).toBeVisible({ + timeout: 5_000, + }); + }); + + test("shows expand separator with unmodified line count between hunks", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedExpansionTask(testPage, apiClient, seedData); + await openChangesTab(testPage); + await openExpansionFileDiff(testPage); + + await expect(testPage.getByText("HUNK_TOP", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + + // Pierre Diffs renders a separator between hunks showing the hidden line count. + // The separator contains img elements (chevron arrows) for expanding. + const middleSeparator = testPage.getByText(/\d+ unmodified lines/).nth(1); + await expect(middleSeparator).toBeVisible({ timeout: 20_000 }); + }); + + test("clicking expand arrow reveals the collapsed middle lines", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedExpansionTask(testPage, apiClient, seedData); + await openChangesTab(testPage); + await openExpansionFileDiff(testPage); + + await expect(testPage.getByText("HUNK_TOP", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + + // Wait for expand buttons to appear in the shadow DOM. They load + // asynchronously after full file content is fetched via WebSocket. + await testPage.waitForFunction( + () => { + const container = document.querySelector("diffs-container"); + const shadow = container?.shadowRoot; + if (!shadow) return false; + return shadow.querySelectorAll("[data-expand-button]").length >= 3; + }, + null, + { timeout: 20_000 }, + ); + + // Click the middle separator's expand-up button to reveal lines from the + // top of the collapsed gap. The middle separator is the only one without + // data-separator-first or data-separator-last attributes. + await testPage.evaluate(() => { + const container = document.querySelector("diffs-container"); + const shadow = container!.shadowRoot!; + const sel = + "[data-separator='line-info']:not([data-separator-first]):not([data-separator-last])"; + const btn = shadow.querySelector(`${sel} [data-expand-up]`); + if (!btn) throw new Error("Middle separator expand-up button not found"); + btn.click(); + }); + + // Line 60 is within the first 20 lines revealed by expanding from the top hunk. + await expect(testPage.getByText("original_060", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + }); + + test("expand-all button reveals all collapsed lines at once", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedExpansionTask(testPage, apiClient, seedData); + await openChangesTab(testPage); + await openExpansionFileDiff(testPage); + + // Wait for both hunks and the separator to be present + await expect(testPage.getByText("HUNK_TOP", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + await expect(testPage.getByText(/\d+ unmodified lines/).first()).toBeVisible({ + timeout: 20_000, + }); + + // The "Expand all lines" button is in the Pierre Diffs header toolbar. + // It contains a tabler-icon-fold-down SVG icon. + const expandAllBtn = testPage.locator("button:has(svg.tabler-icon-fold-down)"); + await expect(expandAllBtn).toBeVisible({ timeout: 10_000 }); + await expandAllBtn.click(); + + // After expanding, all original lines should be visible — pick a line + // from the middle of the previously collapsed region. + await expect(testPage.getByText("original_025", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + await expect(testPage.getByText("original_040", { exact: false })).toBeVisible({ + timeout: 5_000, + }); + + // The "unmodified lines" separators should be gone + await expect(testPage.getByText(/\d+ unmodified lines/)).toHaveCount(0, { + timeout: 5_000, + }); + }); +}); diff --git a/apps/web/lib/state/editor-resolver-store.ts b/apps/web/lib/state/editor-resolver-store.ts index e51e73646b..95e766b3eb 100644 --- a/apps/web/lib/state/editor-resolver-store.ts +++ b/apps/web/lib/state/editor-resolver-store.ts @@ -15,7 +15,7 @@ const PIERRE_DIFFS: EditorProvider = "pierre-diffs"; const VALID_PROVIDERS: Record = { "code-editor": [MONACO, "codemirror"], - "diff-viewer": [MONACO, PIERRE_DIFFS], + "diff-viewer": [MONACO, PIERRE_DIFFS], // NOTE: diff expansion not functional with Monaco "chat-code-block": ["shiki", MONACO, "codemirror"], "chat-diff": [MONACO, PIERRE_DIFFS], "plan-editor": ["tiptap"], diff --git a/apps/web/lib/ws/workspace-files.ts b/apps/web/lib/ws/workspace-files.ts index d2f3196e83..dfded8fb36 100644 --- a/apps/web/lib/ws/workspace-files.ts +++ b/apps/web/lib/ws/workspace-files.ts @@ -35,6 +35,23 @@ export async function requestFileContent( }); } +/** + * Request file content at a specific git ref (branch, commit, HEAD, etc.) + * Used for diff expansion to fetch old/base version of a file. + */ +export async function requestFileContentAtRef( + client: WebSocketClient, + sessionId: string, + path: string, + ref: string, +): Promise { + return client.request("workspace.file.get_at_ref", { + session_id: sessionId, + path, + ref, + }); +} + /** * Search for files in the workspace matching a query */