Add a read-only Files tab browsing a task's worktree
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func TestTaskFiles(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
p := h.createProject("demo")
|
||||
task := api.Task{}
|
||||
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "files"}, &task)
|
||||
wt := task.WorktreePath
|
||||
|
||||
outside := t.TempDir()
|
||||
writeFile(t, filepath.Join(outside, "secret"), "secret\n")
|
||||
err := os.Mkdir(filepath.Join(wt, "src"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(wt, "src", "main.go"), "package main\n")
|
||||
writeFile(t, filepath.Join(wt, "src", "main.o"), "obj")
|
||||
writeFile(t, filepath.Join(wt, ".gitignore"), "*.o\n/out/\n")
|
||||
err = os.Mkdir(filepath.Join(wt, "out"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(wt, "out", "app"), "bin")
|
||||
writeFile(t, filepath.Join(wt, "blob.bin"), "a\x00b")
|
||||
writeFile(t, filepath.Join(wt, "big.txt"), strings.Repeat("é", 200_000))
|
||||
for name, target := range map[string]string{
|
||||
"inlink": "src",
|
||||
"outlink": filepath.Join(outside, "secret"),
|
||||
"outdir": outside,
|
||||
"dangling": "nope",
|
||||
} {
|
||||
err = os.Symlink(target, filepath.Join(wt, name))
|
||||
if err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
dir := func(path string) (api.DirListing, int) {
|
||||
l := api.DirListing{}
|
||||
code := h.do("GET", "/api/tasks/"+task.Id+"/dir?path="+url.QueryEscape(path), nil, &l)
|
||||
return l, code
|
||||
}
|
||||
file := func(path string) (api.FileContent, int) {
|
||||
f := api.FileContent{}
|
||||
code := h.do("GET", "/api/tasks/"+task.Id+"/file?path="+url.QueryEscape(path), nil, &f)
|
||||
return f, code
|
||||
}
|
||||
|
||||
root, code := dir("")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list root: %d", code)
|
||||
}
|
||||
names := func(l api.DirListing) string {
|
||||
got := []string{}
|
||||
for _, e := range l.Entries {
|
||||
s := string(e.Type) + ":" + e.Name
|
||||
if e.Ignored {
|
||||
s += "!"
|
||||
}
|
||||
got = append(got, s)
|
||||
}
|
||||
return strings.Join(got, " ")
|
||||
}
|
||||
want := "dir:inlink dir:out! dir:src file:.gitignore file:README file:big.txt file:blob.bin other:dangling other:outdir other:outlink"
|
||||
if names(root) != want {
|
||||
t.Fatalf("root listing %q, want %q", names(root), want)
|
||||
}
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"src": "file:main.go file:main.o!",
|
||||
"inlink": "file:main.go file:main.o!",
|
||||
"out": "file:app!",
|
||||
} {
|
||||
sub, code := dir(path)
|
||||
if code != http.StatusOK || sub.Path != path || names(sub) != want {
|
||||
t.Fatalf("list %s: %d %q, want %q", path, code, names(sub), want)
|
||||
}
|
||||
}
|
||||
|
||||
f, code := file("src/main.go")
|
||||
if code != http.StatusOK || f.Text != "package main\n" || f.Binary || f.Truncated {
|
||||
t.Fatalf("read text: %d %+v", code, f)
|
||||
}
|
||||
|
||||
f, code = file("blob.bin")
|
||||
if code != http.StatusOK || !f.Binary || f.Text != "" || f.Size != 3 {
|
||||
t.Fatalf("read binary: %d %+v", code, f)
|
||||
}
|
||||
|
||||
f, code = file("big.txt")
|
||||
if code != http.StatusOK || f.Binary || !f.Truncated || f.Size != 400_000 || len(f.Text) != 256<<10 {
|
||||
t.Fatalf("read big: %d binary=%v truncated=%v size=%d len=%d", code, f.Binary, f.Truncated, f.Size, len(f.Text))
|
||||
}
|
||||
|
||||
for _, path := range []string{"../secret", "/etc/passwd", "src/../../x", "outlink", "outdir/secret", ".git", "src"} {
|
||||
_, code = file(path)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("read %s: want 400, got %d", path, code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"..", "outdir", "README"} {
|
||||
_, code = dir(path)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("list %s: want 400, got %d", path, code)
|
||||
}
|
||||
}
|
||||
_, code = file("missing")
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("read missing: want 404, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ func TestServesEmbeddedSPA(t *testing.T) {
|
||||
"/dist/dom.js": "text/javascript",
|
||||
"/dist/api.js": "text/javascript",
|
||||
"/dist/settings.js": "text/javascript",
|
||||
"/dist/files.js": "text/javascript",
|
||||
"/sw.js": "text/javascript",
|
||||
"/dist/mermaid.js": "text/javascript",
|
||||
"/vendor/mermaid.min.js": "text/javascript",
|
||||
|
||||
Reference in New Issue
Block a user