Compare commits

...

1 Commits

Author SHA1 Message Date
Danielle Maywood 2177ae857f feat(site): add expand-context support to diff viewer
Add lazy per-file context expansion for local diffs. When viewing a
diff in the Git panel, each file header shows an "Expand context"
button. Clicking it fetches the full old (HEAD) and new (working tree)
file contents via a new coderd proxy endpoint, re-parses the diff with
@pierre/diffs processFile (which sets isPartial: false), and swaps in
the enriched FileDiffMetadata. The library's native hunk separator
expansion then takes over.

Backend:
- agent/agentgit: new GET /api/v0/git/show endpoint returning file
  contents at a git ref (with binary/size guards)
- codersdk/workspacesdk: GitShowFile SDK method on AgentConn
- coderd: new GET /api/experimental/chats/{chat}/file-content proxy
  endpoint that dials the agent for old (git show HEAD) or new
  (working tree read) file contents

Frontend:
- expandFileDiff utility wrapping processFile with oldFile/newFile
- extractFilePatch utility to split multi-file diffs
- DiffViewer: expansion state, ExpandContextButton, effectiveFiles
  overlay
- LocalDiffPanel: wired to fetch via Promise.allSettled
- Updated @pierre/diffs 1.1.0-beta.19 -> 1.1.7
- Replaced deprecated enableHoverUtility with enableGutterUtility
2026-03-31 13:26:02 +00:00
18 changed files with 946 additions and 60 deletions
+81
View File
@@ -1,8 +1,13 @@
package agentgit
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@@ -30,10 +35,15 @@ func NewAPI(logger slog.Logger, pathStore *PathStore, opts ...Option) *API {
}
}
// maxShowFileSize is the maximum file size returned by the show
// endpoint. Files larger than this are rejected with 422.
const maxShowFileSize = 512 * 1024 // 512 KB
// Routes returns the chi router for mounting at /api/v0/git.
func (a *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/watch", a.handleWatch)
r.Get("/show", a.handleShow)
return r
}
@@ -145,3 +155,74 @@ func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
}
}
}
// GitShowResponse is the JSON response for the show endpoint.
type GitShowResponse struct {
Contents string `json:"contents"`
}
func (a *API) handleShow(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
repoRoot := r.URL.Query().Get("repo_root")
filePath := r.URL.Query().Get("path")
ref := r.URL.Query().Get("ref")
if repoRoot == "" || filePath == "" || ref == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing required query parameters.",
Detail: "repo_root, path, and ref are required.",
})
return
}
// Validate that repo_root is a git repository by checking for
// a .git entry.
gitPath := filepath.Join(repoRoot, ".git")
if _, err := os.Stat(gitPath); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Not a git repository.",
Detail: repoRoot + " does not contain a .git directory.",
})
return
}
// Run `git show ref:path` to retrieve the file at the given
// ref.
//nolint:gosec // ref and filePath are user-provided but we
// intentionally pass them to git.
cmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "show", ref+":"+filePath)
out, err := cmd.Output()
if err != nil {
// git show exits non-zero when the path doesn't exist at
// the given ref.
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "File not found.",
Detail: filePath + " does not exist at ref " + ref + ".",
})
return
}
// Check if the file is binary by looking for null bytes in
// the first 8 KB.
checkLen := min(len(out), 8*1024)
if bytes.ContainsRune(out[:checkLen], '\x00') {
httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{
Message: "binary file",
})
return
}
if len(out) > maxShowFileSize {
httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{
Message: "file too large",
})
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_ = json.NewEncoder(rw).Encode(GitShowResponse{
Contents: string(out),
})
}
+117
View File
@@ -0,0 +1,117 @@
package agentgit_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentgit"
)
func TestGitShow_ReturnsFileAtHEAD(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
targetFile := filepath.Join(repoDir, "hello.txt")
// Write and commit a file with known content.
require.NoError(t, os.WriteFile(targetFile, []byte("committed content\n"), 0o600))
gitCmd(t, repoDir, "add", "hello.txt")
gitCmd(t, repoDir, "commit", "-m", "add hello")
// Modify the working tree version so it differs from HEAD.
require.NoError(t, os.WriteFile(targetFile, []byte("working tree content\n"), 0o600))
logger := slogtest.Make(t, nil)
api := agentgit.NewAPI(logger, nil)
req := httptest.NewRequest(http.MethodGet, "/show?repo_root="+repoDir+"&path=hello.txt&ref=HEAD", nil)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp agentgit.GitShowResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
require.Equal(t, "committed content\n", resp.Contents)
}
func TestGitShow_FileNotFound(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
logger := slogtest.Make(t, nil)
api := agentgit.NewAPI(logger, nil)
req := httptest.NewRequest(http.MethodGet, "/show?repo_root="+repoDir+"&path=nonexistent.txt&ref=HEAD", nil)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusNotFound, rec.Code)
}
func TestGitShow_InvalidRepoRoot(t *testing.T) {
t.Parallel()
notARepo := t.TempDir()
logger := slogtest.Make(t, nil)
api := agentgit.NewAPI(logger, nil)
req := httptest.NewRequest(http.MethodGet, "/show?repo_root="+notARepo+"&path=file.txt&ref=HEAD", nil)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestGitShow_BinaryFile(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
// Create a file with null bytes to simulate binary content.
binPath := filepath.Join(repoDir, "binary.dat")
require.NoError(t, os.WriteFile(binPath, []byte("hello\x00world"), 0o600))
gitCmd(t, repoDir, "add", "binary.dat")
gitCmd(t, repoDir, "commit", "-m", "add binary")
logger := slogtest.Make(t, nil)
api := agentgit.NewAPI(logger, nil)
req := httptest.NewRequest(http.MethodGet, "/show?repo_root="+repoDir+"&path=binary.dat&ref=HEAD", nil)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
require.Contains(t, rec.Body.String(), "binary file")
}
func TestGitShow_FileTooLarge(t *testing.T) {
t.Parallel()
repoDir := initTestRepo(t)
// Create a file exceeding 512 KB.
largePath := filepath.Join(repoDir, "large.txt")
content := strings.Repeat("x", 512*1024+1)
require.NoError(t, os.WriteFile(largePath, []byte(content), 0o600))
gitCmd(t, repoDir, "add", "large.txt")
gitCmd(t, repoDir, "commit", "-m", "add large file")
logger := slogtest.Make(t, nil)
api := agentgit.NewAPI(logger, nil)
req := httptest.NewRequest(http.MethodGet, "/show?repo_root="+repoDir+"&path=large.txt&ref=HEAD", nil)
rec := httptest.NewRecorder()
api.Routes().ServeHTTP(rec, req)
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
require.Contains(t, rec.Body.String(), "file too large")
}
+1
View File
@@ -1236,6 +1236,7 @@ func New(options *Options) *API {
r.Post("/interrupt", api.interruptChat)
r.Post("/title/regenerate", api.regenerateChatTitle)
r.Get("/diff", api.getChatDiffContents)
r.Get("/file-content", api.getChatFileContent)
r.Route("/queue/{queuedMessage}", func(r chi.Router) {
r.Delete("/", api.deleteChatQueuedMessage)
r.Post("/promote", api.promoteChatQueuedMessage)
+130
View File
@@ -5218,3 +5218,133 @@ func (api *API) prInsights(rw http.ResponseWriter, r *http.Request) {
RecentPRs: prEntries,
})
}
// maxFileContentBytes is the maximum size of file contents returned
// by the file-content proxy endpoint. This prevents unbounded reads
// when the agent serves very large files.
const maxFileContentBytes = 10 << 20 // 10 MiB
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatFileContent(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
repoRoot := r.URL.Query().Get("repo_root")
filePath := r.URL.Query().Get("path")
side := r.URL.Query().Get("side")
if repoRoot == "" || filePath == "" || side == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing required query parameters.",
Detail: "repo_root, path, and side are required.",
})
return
}
if side != "old" && side != "new" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid side parameter.",
Detail: "side must be \"old\" or \"new\".",
})
return
}
if !chat.WorkspaceID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat has no workspace.",
})
return
}
agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, chat.WorkspaceID.UUID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}
if len(agents) == 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Chat workspace has no agents.",
})
return
}
apiAgent, err := db2sdk.WorkspaceAgent(
api.DERPMap(),
*api.TailnetCoordinator.Load(),
agents[0],
nil,
nil,
nil,
api.AgentInactiveDisconnectTimeout,
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected),
})
return
}
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, agents[0].ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error dialing workspace agent.",
Detail: err.Error(),
})
return
}
defer release()
var contents string
if side == "old" {
// Retrieve the file at HEAD from git.
contents, err = agentConn.GitShowFile(ctx, repoRoot, filePath, "HEAD")
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to read file from git.",
Detail: err.Error(),
})
return
}
} else {
// Read the working tree copy of the file.
absPath := repoRoot + "/" + filePath
rc, _, err := agentConn.ReadFile(ctx, absPath, 0, maxFileContentBytes)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to read working tree file.",
Detail: err.Error(),
})
return
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to read file body.",
Detail: err.Error(),
})
return
}
contents = string(data)
}
httpapi.Write(ctx, rw, http.StatusOK, map[string]string{
"contents": contents,
})
}
+29
View File
@@ -10,6 +10,7 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"sync"
"time"
@@ -84,6 +85,7 @@ type AgentConn interface {
ReadFileLines(ctx context.Context, path string, offset, limit int64, limits ReadFileLinesLimits) (ReadFileLinesResponse, error)
WriteFile(ctx context.Context, path string, reader io.Reader) error
EditFiles(ctx context.Context, edits FileEditRequest) error
GitShowFile(ctx context.Context, repoRoot, path, ref string) (string, error)
SSH(ctx context.Context) (*gonet.TCPConn, error)
SSHClient(ctx context.Context) (*ssh.Client, error)
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
@@ -1095,6 +1097,33 @@ func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) error
return nil
}
// GitShowFile retrieves a file's contents from a git repository at a
// given ref (e.g. "HEAD").
func (c *agentConn) GitShowFile(ctx context.Context, repoRoot, path, ref string) (string, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.apiRequest(ctx, http.MethodGet, fmt.Sprintf(
"/api/v0/git/show?repo_root=%s&path=%s&ref=%s",
url.QueryEscape(repoRoot), url.QueryEscape(path), url.QueryEscape(ref),
), nil)
if err != nil {
return "", xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return "", codersdk.ReadBodyAsError(res)
}
var resp struct {
Contents string `json:"contents"`
}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return "", xerrors.Errorf("decode response: %w", err)
}
return resp.Contents, nil
}
// apiRequest makes a request to the workspace agent's HTTP API server.
func (c *agentConn) apiRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
ctx, span := tracing.StartSpan(ctx)
+1 -1
View File
@@ -62,7 +62,7 @@
"@mui/system": "5.18.0",
"@mui/x-tree-view": "7.29.10",
"@novnc/novnc": "^1.5.0",
"@pierre/diffs": "1.1.0-beta.19",
"@pierre/diffs": "1.1.7",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
+49 -49
View File
@@ -86,8 +86,8 @@ importers:
specifier: ^1.5.0
version: 1.5.0
'@pierre/diffs':
specifier: 1.1.0-beta.19
version: 1.1.0-beta.19(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
specifier: 1.1.7
version: 1.1.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-avatar':
specifier: 1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
@@ -1720,8 +1720,8 @@ packages:
cpu: [x64]
os: [win32]
'@pierre/diffs@1.1.0-beta.19':
resolution: {integrity: sha512-XxGPKkVW+1t2KJQfgjmSnS+93nI9+ACJl1XjhF3Lo4BdQJOxV3pHeyix31ySn/m/1llq6O/7bXucE0OYCK6Kog==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.0-beta.19.tgz}
'@pierre/diffs@1.1.7':
resolution: {integrity: sha512-FWs2hHrjZPXmJl6ewnfFzOjNEM3aeSH1CB8ynZg4SOg95Wc5AxomeyJJhXf44PK9Cc+PNm1CgsJ1IvOdfgHyHA==, tarball: https://registry.npmjs.org/@pierre/diffs/-/diffs-1.1.7.tgz}
peerDependencies:
react: ^18.3.1 || ^19.0.0
react-dom: ^18.3.1 || ^19.0.0
@@ -2400,26 +2400,26 @@ packages:
cpu: [x64]
os: [win32]
'@shikijs/core@3.22.0':
resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==, tarball: https://registry.npmjs.org/@shikijs/core/-/core-3.22.0.tgz}
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==, tarball: https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz}
'@shikijs/engine-javascript@3.22.0':
resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==, tarball: https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.22.0.tgz}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==, tarball: https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz}
'@shikijs/engine-oniguruma@3.22.0':
resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==, tarball: https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.22.0.tgz}
'@shikijs/engine-oniguruma@3.23.0':
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==, tarball: https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz}
'@shikijs/langs@3.22.0':
resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==, tarball: https://registry.npmjs.org/@shikijs/langs/-/langs-3.22.0.tgz}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==, tarball: https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz}
'@shikijs/themes@3.22.0':
resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==, tarball: https://registry.npmjs.org/@shikijs/themes/-/themes-3.22.0.tgz}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==, tarball: https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz}
'@shikijs/transformers@3.22.0':
resolution: {integrity: sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==, tarball: https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.22.0.tgz}
'@shikijs/transformers@3.23.0':
resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==, tarball: https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz}
'@shikijs/types@3.22.0':
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==, tarball: https://registry.npmjs.org/@shikijs/types/-/types-3.22.0.tgz}
'@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==, tarball: https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==, tarball: https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz}
@@ -5601,8 +5601,8 @@ packages:
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==, tarball: https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz}
oniguruma-to-es@4.3.4:
resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==, tarball: https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz}
oniguruma-to-es@4.3.5:
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==, tarball: https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz}
open@10.2.0:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==, tarball: https://registry.npmjs.org/open/-/open-10.2.0.tgz}
@@ -6298,8 +6298,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, tarball: https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz}
engines: {node: '>=8'}
shiki@3.22.0:
resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==, tarball: https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz}
shiki@3.23.0:
resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==, tarball: https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==, tarball: https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz}
@@ -8641,16 +8641,16 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.14.0':
optional: true
'@pierre/diffs@1.1.0-beta.19(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
'@pierre/diffs@1.1.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@pierre/theme': 0.0.22
'@shikijs/transformers': 3.22.0
'@shikijs/transformers': 3.23.0
diff: 8.0.3
hast-util-to-html: 9.0.5
lru_map: 0.4.1
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
shiki: 3.22.0
shiki: 3.23.0
'@pierre/theme@0.0.22': {}
@@ -9278,38 +9278,38 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.53.3':
optional: true
'@shikijs/core@3.22.0':
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.22.0':
'@shikijs/engine-javascript@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.4
oniguruma-to-es: 4.3.5
'@shikijs/engine-oniguruma@3.22.0':
'@shikijs/engine-oniguruma@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@3.22.0':
'@shikijs/langs@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/themes@3.22.0':
'@shikijs/themes@3.23.0':
dependencies:
'@shikijs/types': 3.22.0
'@shikijs/types': 3.23.0
'@shikijs/transformers@3.22.0':
'@shikijs/transformers@3.23.0':
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/core': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/types@3.22.0':
'@shikijs/types@3.23.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
@@ -11654,7 +11654,7 @@ snapshots:
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
mdast-util-to-hast: 13.2.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
@@ -13223,7 +13223,7 @@ snapshots:
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.4:
oniguruma-to-es@4.3.5:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
@@ -14041,14 +14041,14 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@3.22.0:
shiki@3.23.0:
dependencies:
'@shikijs/core': 3.22.0
'@shikijs/engine-javascript': 3.22.0
'@shikijs/engine-oniguruma': 3.22.0
'@shikijs/langs': 3.22.0
'@shikijs/themes': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/engine-oniguruma': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
+13
View File
@@ -3220,6 +3220,19 @@ class ExperimentalApiMethods {
return response.data;
};
getChatFileContent = async (
chatId: string,
repoRoot: string,
path: string,
side: "old" | "new",
): Promise<{ contents: string }> => {
const params = new URLSearchParams({ repo_root: repoRoot, path, side });
const response = await this.axios.get<{ contents: string }>(
`/api/experimental/chats/${chatId}/file-content?${params}`,
);
return response.data;
};
getChatModels = async (): Promise<TypesGen.ChatModelsResponse> => {
const response = await this.axios.get<TypesGen.ChatModelsResponse>(
"/api/experimental/chats/models",
@@ -370,9 +370,9 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
<GitPanel
chatId={agentId}
prTab={ prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
@@ -193,6 +193,12 @@ interface CommentableDiffViewerProps {
scrollToFile?: string | null;
/** Called after scrollToFile has been processed. */
onScrollToFileComplete?: () => void;
/** Callback to fetch file contents for context expansion. */
onRequestFileContents?: (fileName: string) => Promise<{
oldContents: string | null;
newContents: string | null;
patchString: string;
} | null>;
}
/**
@@ -1,5 +1,5 @@
import type { DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { parsePatchFiles, processFile } from "@pierre/diffs";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, waitFor } from "storybook/test";
import type { DiffStyle } from "../DiffViewer/DiffViewer";
@@ -429,3 +429,86 @@ export const RenameWithLongPaths: Story = {
parsedFiles: renameFiles,
},
};
// -------------------------------------------------------------------
// Expandable context stories
// -------------------------------------------------------------------
// A diff with full file contents provided via processFile so the
// library has isPartial: false and renders native expand buttons.
// biome-ignore format: raw diff string must preserve exact whitespace
const expandableFilePatch = [
"diff --git a/src/config.ts b/src/config.ts",
"index abc1234..def5678 100644",
"--- a/src/config.ts",
"+++ b/src/config.ts",
"@@ -5,3 +5,4 @@ const config = {",
" host: \"localhost\",",
" debug: false,",
"+ verbose: true,",
" };",
].join("\n");
const expandableOldContents = [
"// Configuration file",
"import { defaults } from './defaults';",
"",
"const config = {",
' host: "localhost",',
" debug: false,",
"};",
"",
"export default config;",
].join("\n");
const expandableNewContents = [
"// Configuration file",
"import { defaults } from './defaults';",
"",
"const config = {",
' host: "localhost",',
" debug: false,",
" verbose: true,",
"};",
"",
"export default config;",
].join("\n");
const expandableFile = processFile(expandableFilePatch, {
oldFile: { name: "src/config.ts", contents: expandableOldContents },
newFile: { name: "src/config.ts", contents: expandableNewContents },
isGitDiff: true,
});
const expandableFiles = expandableFile ? [expandableFile] : [];
export const WithExpandableContext: Story = {
args: {
parsedFiles: expandableFiles,
},
play: async ({ canvasElement }) => {
// The enriched diff should have isPartial: false, which means
// the library renders native expand buttons in the separator.
await waitFor(() => {
const container = canvasElement.querySelector("diffs-container");
expect(container).not.toBeNull();
});
},
};
// Story simulating the loading state while expansion is in progress.
// The onRequestFileContents callback never resolves so the button
// stays in its loading state.
export const WithExpansionLoading: Story = {
args: {
parsedFiles: multiHunkFiles,
onRequestFileContents: () => new Promise(() => {}),
},
play: async ({ canvasElement }) => {
// The "Expand context" button should be visible in the header.
await waitFor(() => {
const btn = canvasElement.querySelector("button");
expect(btn).not.toBeNull();
});
},
};
@@ -7,7 +7,7 @@ import type {
} from "@pierre/diffs";
import { Virtualizer } from "@pierre/diffs";
import { FileDiff, VirtualizerContext } from "@pierre/diffs/react";
import { ChevronRightIcon } from "lucide-react";
import { ChevronDownIcon, ChevronRightIcon, LoaderIcon } from "lucide-react";
import {
type ComponentProps,
type FC,
@@ -23,6 +23,7 @@ import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { Skeleton } from "#/components/Skeleton/Skeleton";
import { cn } from "#/utils/cn";
import { changeColor, changeLabel } from "../../utils/diffColors";
import { expandFileDiff } from "../../utils/expandFileDiff";
import {
DIFFS_FONT_STYLE,
getDiffViewerOptions,
@@ -91,6 +92,18 @@ interface DiffViewerProps {
scrollToFile?: string | null;
/** Called after scrollToFile has been processed. */
onScrollToFileComplete?: () => void;
/**
* Callback to fetch file contents for context expansion. When
* provided, partial diffs show an "Expand context" button in
* the file header. Clicking it fetches the full file contents
* and re-parses the diff so the library's native hunk
* expansion takes over.
*/
onRequestFileContents?: (fileName: string) => Promise<{
oldContents: string | null;
newContents: string | null;
patchString: string;
} | null>;
}
// -------------------------------------------------------------------
@@ -432,6 +445,7 @@ interface LazyFileDiffProps {
lineAnnotations?: DiffLineAnnotation<string>[];
renderAnnotation?: (annotation: DiffLineAnnotation<string>) => ReactNode;
selectedLines?: SelectedLineRange | null;
renderHeaderMetadata?: (fileDiff: FileDiffMetadata) => ReactNode;
}
const LazyFileDiff: FC<LazyFileDiffProps> = ({
@@ -440,6 +454,7 @@ const LazyFileDiff: FC<LazyFileDiffProps> = ({
lineAnnotations,
renderAnnotation: renderAnnotationProp,
selectedLines,
renderHeaderMetadata: renderHeaderMetadataProp,
}) => {
const placeholderRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
@@ -489,10 +504,57 @@ const LazyFileDiff: FC<LazyFileDiffProps> = ({
lineAnnotations={lineAnnotations}
renderAnnotation={renderAnnotationProp}
selectedLines={selectedLines}
renderHeaderMetadata={renderHeaderMetadataProp}
/>
);
};
// -------------------------------------------------------------------
// Expand context button for file headers
// -------------------------------------------------------------------
/**
* Small button rendered in the file header via `renderHeaderMetadata`.
* When clicked it triggers the expansion flow that fetches full file
* contents and re-parses the diff with context expansion enabled.
*/
const ExpandContextButton: FC<{
fileName: string;
isLoading: boolean;
onClick: (fileName: string) => void;
}> = ({ fileName, isLoading, onClick }) => {
return (
<button
type="button"
disabled={isLoading}
onClick={(e) => {
e.stopPropagation();
onClick(fileName);
}}
className={cn(
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium",
"border border-solid border-border-default bg-transparent",
"text-content-secondary transition-colors",
"hover:bg-surface-secondary hover:text-content-primary",
"cursor-pointer disabled:cursor-default disabled:opacity-60",
"outline-none focus-visible:ring-2 focus-visible:ring-content-link",
)}
>
{isLoading ? (
<>
<LoaderIcon className="size-3 animate-spin" />
Loading
</>
) : (
<>
<ChevronDownIcon className="size-3" />
Expand context
</>
)}
</button>
);
};
// -------------------------------------------------------------------
// Main component
// -------------------------------------------------------------------
@@ -511,15 +573,88 @@ export const DiffViewer: FC<DiffViewerProps> = ({
renderAnnotation,
scrollToFile,
onScrollToFileComplete,
onRequestFileContents,
}) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
// ---------------------------------------------------------------
// Expansion state: track which files have been enriched with
// full contents so the library's native hunk expansion works.
// ---------------------------------------------------------------
const [expandedFiles, setExpandedFiles] = useState<
Map<string, FileDiffMetadata>
>(new Map());
const [loadingFiles, setLoadingFiles] = useState<Set<string>>(new Set());
// Reset expansion state when the upstream parsedFiles change
// (e.g. the user switches to a different diff).
const prevFilesRef = useRef(parsedFiles);
if (prevFilesRef.current !== parsedFiles) {
prevFilesRef.current = parsedFiles;
if (expandedFiles.size > 0) setExpandedFiles(new Map());
if (loadingFiles.size > 0) setLoadingFiles(new Set());
}
const requestExpansion = (fileName: string) => {
if (
!onRequestFileContents ||
loadingFiles.has(fileName) ||
expandedFiles.has(fileName)
) {
return;
}
setLoadingFiles((prev) => new Set(prev).add(fileName));
onRequestFileContents(fileName)
.then((result) => {
if (!result) return;
const enriched = expandFileDiff(
fileName,
result.patchString,
result.oldContents,
result.newContents,
);
if (enriched) {
setExpandedFiles((prev) => {
const next = new Map(prev);
next.set(fileName, enriched);
return next;
});
}
})
.catch(() => {
// Silently ignore — the button just stops loading
// and the user can retry.
})
.finally(() => {
setLoadingFiles((prev) => {
const next = new Set(prev);
next.delete(fileName);
return next;
});
});
};
// Build effective file list: overlay expanded files on top of
// the original parsedFiles so enriched diffs with full context
// replace the partial originals.
const effectiveFiles: readonly FileDiffMetadata[] =
expandedFiles.size > 0
? parsedFiles.map((f) => expandedFiles.get(f.name) ?? f)
: parsedFiles;
const diffOptions = (() => {
const base = getDiffViewerOptions(isDark);
return {
...base,
diffStyle,
// Enable the library's native expand-context feature so
// that enriched (isPartial: false) files render expand
// buttons in their hunk separators.
expandUnchanged: true,
hunkSeparators: "line-info" as const,
// Extend the base CSS to make file headers sticky so they
// remain visible while scrolling through long diffs.
unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`,
@@ -530,7 +665,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
enableGutterUtility: true,
onLineSelected() {
// TODO: Make this add context to the input so the
// user can type.
@@ -546,7 +681,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
...diffOptions,
overflow: "wrap" as const,
enableLineSelection: true,
enableHoverUtility: true,
enableGutterUtility: true,
...(onLineNumberClick && {
onLineNumberClick: (props: {
lineNumber: number;
@@ -567,7 +702,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
},
});
const fileTree = buildFileTree(parsedFiles);
const fileTree = buildFileTree(effectiveFiles);
// Sort diff blocks in the same order the file tree displays them
// (directories first, then alphabetical) so the rendering is
@@ -584,7 +719,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
}
};
walk(fileTree);
return [...parsedFiles].sort(
return [...effectiveFiles].sort(
(a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0),
);
})();
@@ -835,6 +970,22 @@ export const DiffViewer: FC<DiffViewerProps> = ({
>
{sortedFiles.map((fileDiff, i) => {
const isLast = i === sortedFiles.length - 1;
const isFileExpanded = expandedFiles.has(fileDiff.name);
const isFileLoading = loadingFiles.has(fileDiff.name);
// Show the expand button for partial files when
// the parent provides a content fetcher.
const headerMetadata =
onRequestFileContents && fileDiff.isPartial && !isFileExpanded
? () => (
<ExpandContextButton
fileName={fileDiff.name}
isLoading={isFileLoading}
onClick={requestExpansion}
/>
)
: undefined;
return (
<div
key={fileDiff.name}
@@ -853,6 +1004,7 @@ export const DiffViewer: FC<DiffViewerProps> = ({
selectedLines={
perFileSelectedLines?.get(fileDiff.name) ?? null
}
renderHeaderMetadata={headerMetadata}
/>
{isLast && (
<div className="flex items-center justify-center py-4 text-xs text-content-secondary">
@@ -1,11 +1,14 @@
import { parsePatchFiles } from "@pierre/diffs";
import type { FC, RefObject } from "react";
import { API } from "#/api/api";
import type { WorkspaceAgentRepoChanges } from "#/api/typesGenerated";
import { extractFilePatch } from "../../utils/extractFilePatch";
import type { ChatMessageInputRef } from "../AgentChatInput";
import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
import type { DiffStyle } from "../DiffViewer/DiffViewer";
interface LocalDiffPanelProps {
chatId: string;
repo: WorkspaceAgentRepoChanges;
isExpanded?: boolean;
diffStyle: DiffStyle;
@@ -13,13 +16,15 @@ interface LocalDiffPanelProps {
}
export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
chatId,
repo,
isExpanded,
diffStyle,
chatInputRef,
}) => {
const diff = repo.unified_diff ?? "";
const parsedFiles = (() => {
const diff = repo.unified_diff;
if (!diff) {
return [];
}
@@ -31,6 +36,43 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
}
})();
const handleRequestFileContents = diff
? async (fileName: string) => {
const patchString = extractFilePatch(diff, fileName);
if (!patchString) return null;
try {
const [oldResult, newResult] = await Promise.allSettled([
API.experimental.getChatFileContent(
chatId,
repo.repo_root,
fileName,
"old",
),
API.experimental.getChatFileContent(
chatId,
repo.repo_root,
fileName,
"new",
),
]);
const oldContents =
oldResult.status === "fulfilled"
? oldResult.value.contents
: null;
const newContents =
newResult.status === "fulfilled"
? newResult.value.contents
: null;
return { oldContents, newContents, patchString };
} catch {
return { oldContents: null, newContents: null, patchString };
}
}
: undefined;
return (
<CommentableDiffViewer
parsedFiles={parsedFiles}
@@ -38,6 +80,7 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
emptyMessage="No file changes."
diffStyle={diffStyle}
chatInputRef={chatInputRef}
onRequestFileContents={handleRequestFileContents}
/>
);
};
@@ -99,6 +99,7 @@ const meta: Meta<typeof GitPanel> = {
title: "pages/AgentsPage/GitPanel",
component: GitPanel,
args: {
chatId: "test-chat",
onRefresh: fn().mockReturnValue(true),
onCommit: fn(),
repositories: new Map(),
@@ -38,6 +38,8 @@ interface DiffStats {
}
interface GitPanelProps {
/** Chat ID for agent API access. */
chatId: string;
/** PR tab data. Omitted if no PR is associated. */
prTab?: {
prNumber: number;
@@ -63,6 +65,7 @@ function repoTabLabel(repoRoot: string): string {
}
export const GitPanel: FC<GitPanelProps> = ({
chatId,
prTab,
repositories,
onRefresh,
@@ -285,6 +288,7 @@ export const GitPanel: FC<GitPanelProps> = ({
/>
) : (
<LocalRepoContent
chatId={chatId}
repoRoot={view.repoRoot}
repo={repositories.get(view.repoRoot)}
diffStats={
@@ -343,6 +347,7 @@ const RemoteContent: FC<{
// ---------------------------------------------------------------
const LocalRepoContent: FC<{
chatId: string;
repoRoot: string;
repo: WorkspaceAgentRepoChanges | undefined;
diffStats: DiffStats;
@@ -351,6 +356,7 @@ const LocalRepoContent: FC<{
diffStyle: DiffStyle;
chatInputRef?: RefObject<ChatMessageInputRef | null>;
}> = ({
chatId,
repoRoot,
repo,
diffStats,
@@ -372,6 +378,7 @@ const LocalRepoContent: FC<{
onCommit={() => onCommit(repoRoot)}
/>
<LocalDiffPanel
chatId={chatId}
repo={repo}
isExpanded={isExpanded}
diffStyle={diffStyle}
@@ -0,0 +1,122 @@
import {
expandFileDiff,
isExpandable,
MAX_EXPANDABLE_FILE_SIZE,
} from "./expandFileDiff";
const simplePatch = `diff --git a/test.ts b/test.ts
index abc1234..def5678 100644
--- a/test.ts
+++ b/test.ts
@@ -1,5 +1,5 @@
line 1
line 2
-old line 3
+new line 3
line 4
line 5
`;
const oldContents = "line 1\nline 2\nold line 3\nline 4\nline 5\n";
const newContents = "line 1\nline 2\nnew line 3\nline 4\nline 5\n";
const newFilePatch = `diff --git a/newfile.ts b/newfile.ts
new file mode 100644
index 0000000..abc1234
--- /dev/null
+++ b/newfile.ts
@@ -0,0 +1,3 @@
+line 1
+line 2
+line 3
`;
const deletedFilePatch = `diff --git a/deleted.ts b/deleted.ts
deleted file mode 100644
index abc1234..0000000
--- a/deleted.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-line 1
-line 2
-line 3
`;
describe("expandFileDiff", () => {
it("returns FileDiffMetadata with isPartial false", () => {
const result = expandFileDiff(
"test.ts",
simplePatch,
oldContents,
newContents,
);
expect(result).not.toBeNull();
expect(result!.isPartial).toBe(false);
});
it("handles new file (null old contents)", () => {
const result = expandFileDiff(
"newfile.ts",
newFilePatch,
null,
"line 1\nline 2\nline 3\n",
);
expect(result).not.toBeNull();
expect(result!.isPartial).toBe(false);
});
it("handles deleted file (null new contents)", () => {
const result = expandFileDiff(
"deleted.ts",
deletedFilePatch,
"line 1\nline 2\nline 3\n",
null,
);
expect(result).not.toBeNull();
});
it("returns empty diff on invalid patch (processFile still succeeds)", () => {
// processFile always succeeds when file contents are provided,
// even if the patch string is garbage — it diffs the files directly.
const result = expandFileDiff(
"test.ts",
"this is not a valid patch at all",
null,
null,
);
expect(result).not.toBeNull();
expect(result!.hunks).toHaveLength(0);
expect(result!.isPartial).toBe(false);
});
it("preserves file name", () => {
const result = expandFileDiff(
"test.ts",
simplePatch,
oldContents,
newContents,
);
expect(result).not.toBeNull();
expect(result!.name).toContain("test.ts");
});
});
describe("isExpandable", () => {
it("returns true for small files", () => {
expect(isExpandable("small content", "small content")).toBe(true);
});
it("returns false when old file exceeds limit", () => {
const huge = "x".repeat(MAX_EXPANDABLE_FILE_SIZE + 1);
expect(isExpandable(huge, "small")).toBe(false);
});
it("returns false when new file exceeds limit", () => {
const huge = "x".repeat(MAX_EXPANDABLE_FILE_SIZE + 1);
expect(isExpandable("small", huge)).toBe(false);
});
it("returns true when both are null", () => {
expect(isExpandable(null, null)).toBe(true);
});
});
@@ -0,0 +1,64 @@
import {
type FileContents,
type FileDiffMetadata,
processFile,
} from "@pierre/diffs";
export const MAX_EXPANDABLE_FILE_SIZE = 512 * 1024; // 512 KB
/**
* Takes a single file's patch string and the full old/new file contents,
* returns an enriched FileDiffMetadata with isPartial: false.
* This enables the @pierre/diffs library's native expand-context feature.
*
* @param fileName - The file name (used for language detection)
* @param patchString - The single-file unified diff string (the portion
* of the full diff that belongs to this file, including the diff --git header)
* @param oldContents - Full old file contents, or null for new files
* @param newContents - Full new file contents, or null for deleted files
* @param cacheKey - Optional cache key for the worker pool
* @returns Enriched FileDiffMetadata, or null if parsing fails
*/
export function expandFileDiff(
fileName: string,
patchString: string,
oldContents: string | null,
newContents: string | null,
cacheKey?: string,
): FileDiffMetadata | null {
// For new files (null old), use empty string so processFile sees both
// sides and can set isPartial: false. Same for deleted files (null new).
const oldFile: FileContents = {
name: fileName,
contents: oldContents ?? "",
};
const newFile: FileContents = {
name: fileName,
contents: newContents ?? "",
};
const result = processFile(patchString, {
oldFile,
newFile,
cacheKey,
isGitDiff: true,
});
return result ?? null;
}
/**
* Checks if a file is eligible for expansion based on content size.
*/
export function isExpandable(
oldContents: string | null,
newContents: string | null,
): boolean {
if (oldContents !== null && oldContents.length > MAX_EXPANDABLE_FILE_SIZE) {
return false;
}
if (newContents !== null && newContents.length > MAX_EXPANDABLE_FILE_SIZE) {
return false;
}
return true;
}
@@ -0,0 +1,37 @@
/**
* Extracts the unified diff section for a single file from a
* multi-file unified diff string. The returned string includes
* the `diff --git` header through to the end of the last hunk
* for that file.
*
* Returns null if the file isn't found in the diff.
*/
export function extractFilePatch(
fullDiff: string,
fileName: string,
): string | null {
// Split the full diff into per-file sections at each
// `diff --git` boundary.
const diffHeaders = [...fullDiff.matchAll(/^diff --git /gm)];
if (diffHeaders.length === 0) return null;
for (let i = 0; i < diffHeaders.length; i++) {
const start = diffHeaders[i].index;
if (start === undefined) continue;
const end =
i + 1 < diffHeaders.length ? diffHeaders[i + 1].index : fullDiff.length;
const section = fullDiff.slice(start, end);
// Check whether this section belongs to the requested file.
// The diff header looks like: diff --git a/path b/path
// We also check --- and +++ lines for renamed files.
if (
section.includes(`a/${fileName}`) ||
section.includes(`b/${fileName}`)
) {
return section;
}
}
return null;
}