Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2177ae857f |
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+49
-49
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user