2026-10-15 07:31:53 -07:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"io/fs"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"path"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"slices"
|
|
|
|
|
"strings"
|
|
|
|
|
"unicode/utf8"
|
|
|
|
|
|
2026-10-18 15:32:07 -07:00
|
|
|
"g.fc.run/ai/byre/internal/api"
|
|
|
|
|
"g.fc.run/ai/byre/internal/git"
|
2026-10-15 07:31:53 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
maxFileBytes = 256 << 10
|
|
|
|
|
maxDirEntries = 5000
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (s *Server) GetTaskDir(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskDirParams) {
|
|
|
|
|
t, _, ok := s.activeTask(w, r, taskID)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
rel := ""
|
|
|
|
|
if params.Path != nil {
|
|
|
|
|
rel = *params.Path
|
|
|
|
|
}
|
|
|
|
|
name, ok := worktreeName(w, rel)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
root, err := os.OpenRoot(t.WorktreePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer root.Close()
|
|
|
|
|
|
|
|
|
|
f, err := root.Open(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
fi, err := f.Stat()
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !fi.IsDir() {
|
|
|
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a directory", rel))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
des, err := f.ReadDir(-1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries := []api.DirEntry{}
|
|
|
|
|
for _, de := range des {
|
|
|
|
|
if de.Name() == ".git" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
entries = append(entries, dirEntry(root, path.Join(name, de.Name()), de))
|
|
|
|
|
}
|
|
|
|
|
slices.SortFunc(entries, func(a, b api.DirEntry) int {
|
|
|
|
|
if (a.Type == api.DirEntryTypeDir) != (b.Type == api.DirEntryTypeDir) {
|
|
|
|
|
if a.Type == api.DirEntryTypeDir {
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
return strings.Compare(a.Name, b.Name)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
listing := api.DirListing{Entries: entries}
|
|
|
|
|
if name != "." {
|
|
|
|
|
listing.Path = name
|
|
|
|
|
}
|
|
|
|
|
if len(entries) > maxDirEntries {
|
|
|
|
|
listing.Entries = entries[:maxDirEntries]
|
|
|
|
|
listing.Truncated = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
real, err := realDir(t.WorktreePath, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
paths := []string{}
|
|
|
|
|
for _, e := range listing.Entries {
|
|
|
|
|
paths = append(paths, path.Join(real, e.Name))
|
|
|
|
|
}
|
|
|
|
|
ignored, err := git.Ignored(t.WorktreePath, paths)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for i, p := range paths {
|
|
|
|
|
listing.Entries[i].Ignored = ignored[p]
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, listing)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func realDir(worktree, name string) (string, error) {
|
|
|
|
|
root, err := filepath.EvalSymlinks(worktree)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
dir, err := filepath.EvalSymlinks(filepath.Join(worktree, name))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
rel, err := filepath.Rel(root, dir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return filepath.ToSlash(rel), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dirEntry(root *os.Root, name string, de fs.DirEntry) api.DirEntry {
|
|
|
|
|
e := api.DirEntry{Name: de.Name(), Type: api.DirEntryTypeOther}
|
|
|
|
|
|
|
|
|
|
var fi fs.FileInfo
|
|
|
|
|
var err error
|
|
|
|
|
if de.Type()&fs.ModeSymlink != 0 {
|
|
|
|
|
e.Symlink = true
|
|
|
|
|
fi, err = root.Stat(name)
|
|
|
|
|
} else {
|
|
|
|
|
fi, err = de.Info()
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return e
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case fi.IsDir():
|
|
|
|
|
e.Type = api.DirEntryTypeDir
|
|
|
|
|
case fi.Mode().IsRegular():
|
|
|
|
|
e.Type = api.DirEntryTypeFile
|
|
|
|
|
e.Size = fi.Size()
|
|
|
|
|
}
|
|
|
|
|
return e
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) GetTaskFile(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskFileParams) {
|
|
|
|
|
t, _, ok := s.activeTask(w, r, taskID)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if params.Path == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, errors.New("path is required"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
name, ok := worktreeName(w, params.Path)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
root, err := os.OpenRoot(t.WorktreePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer root.Close()
|
|
|
|
|
|
|
|
|
|
fi, err := root.Stat(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !fi.Mode().IsRegular() {
|
|
|
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", params.Path))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
f, err := root.Open(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writePathError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
b, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1))
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fc := api.FileContent{Path: name, Size: fi.Size()}
|
|
|
|
|
if len(b) > maxFileBytes {
|
|
|
|
|
fc.Truncated = true
|
|
|
|
|
b = b[:maxFileBytes]
|
|
|
|
|
for n := 0; n < utf8.UTFMax-1 && len(b) > 0 && !utf8.Valid(b); n++ {
|
|
|
|
|
b = b[:len(b)-1]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if bytes.IndexByte(b, 0) >= 0 || !utf8.Valid(b) {
|
|
|
|
|
fc.Binary = true
|
|
|
|
|
writeJSON(w, http.StatusOK, fc)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
fc.Text = string(b)
|
|
|
|
|
writeJSON(w, http.StatusOK, fc)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func worktreeName(w http.ResponseWriter, rel string) (string, bool) {
|
|
|
|
|
if rel == "" {
|
|
|
|
|
return ".", true
|
|
|
|
|
}
|
|
|
|
|
name := filepath.FromSlash(rel)
|
|
|
|
|
if !filepath.IsLocal(name) {
|
|
|
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a path inside the worktree", rel))
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
name = filepath.ToSlash(filepath.Clean(name))
|
|
|
|
|
if slices.Contains(strings.Split(name, "/"), ".git") {
|
|
|
|
|
writeError(w, http.StatusBadRequest, fmt.Errorf("%s is inside .git", rel))
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return name, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writePathError(w http.ResponseWriter, err error) {
|
|
|
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
|
|
|
writeError(w, http.StatusNotFound, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeError(w, http.StatusBadRequest, err)
|
|
|
|
|
}
|