Add a read-only Files tab browsing a task's worktree

This commit is contained in:
Ian Gulliver
2026-10-15 07:31:53 -07:00
parent 4ca6f88861
commit de5b944a0c
16 changed files with 1460 additions and 123 deletions
-2
View File
@@ -28,8 +28,6 @@
PTYs via `github.com/creack/pty`, rendered with xterm.js, one WebSocket per open terminal (the only WebSocket use in byre). Scoped to the task's worktree by default. Shown as a pane tab.
- Decide whether terminals survive a byre restart
Either accept that terminals are lost when byre restarts, or back each terminal with tmux.
- File browser
Read-only, scoped to project and worktree roots. Shown as a pane tab.
- Per-project setup config
A setup script run in each new worktree (dependency install, env file copying), and port allocation so parallel dev servers don't collide.
- Decide the format of per-project setup config
+75
View File
@@ -372,6 +372,47 @@ paths:
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/dir:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
get:
operationId: getTaskDir
description: >
One directory of the task's worktree, directories first, then by name. Entries named .git are left out;
gitignored entries are included and marked.
A path that leaves the worktree, through .. or a symlink, is refused.
parameters:
- { name: path, in: query, required: false, schema: { type: string }, description: "Slash-separated, relative to the worktree root; empty or absent for the root." }
responses:
"200":
description: The directory's entries.
content:
application/json:
schema: { $ref: "#/components/schemas/DirListing" }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/file:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
get:
operationId: getTaskFile
description: >
A regular file in the task's worktree. Text is returned up to a size limit; binary files return no content.
A path that leaves the worktree, through .. or a symlink, is refused.
parameters:
- { name: path, in: query, required: true, schema: { type: string }, description: "Slash-separated, relative to the worktree root." }
responses:
"200":
description: The file.
content:
application/json:
schema: { $ref: "#/components/schemas/FileContent" }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/snapshots:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
@@ -783,6 +824,40 @@ components:
path: { type: string }
patch: { type: string }
DirListing:
type: object
required: [path, entries, truncated]
properties:
path: { type: string, description: "The directory, relative to the worktree root; empty for the root." }
entries:
type: array
items: { $ref: "#/components/schemas/DirEntry" }
truncated: { type: boolean, description: The directory has more entries than were returned. }
DirEntry:
type: object
required: [name, type, symlink, size, ignored]
properties:
name: { type: string }
ignored: { type: boolean, description: "git ignores the entry: .gitignore and the other exclude files match it or a directory above it." }
type:
type: string
enum: [dir, file, other]
x-enum-varnames: [DirEntryTypeDir, DirEntryTypeFile, DirEntryTypeOther]
description: "What the entry is, following a symlink that stays in the worktree. other: a symlink that leaves the worktree or points nowhere, or a device, socket or pipe."
symlink: { type: boolean }
size: { type: integer, format: int64, description: "file: its size in bytes." }
FileContent:
type: object
required: [path, size, binary, truncated, text]
properties:
path: { type: string }
size: { type: integer, format: int64 }
binary: { type: boolean, description: The file isn't UTF-8 text; text is empty. }
truncated: { type: boolean, description: "text holds only the start of the file." }
text: { type: string }
Snapshot:
type: object
required: [id, task_id, agent_session_id, n, commit_sha, tree_sha, event_seq, trigger, created_at]
+7 -1
View File
@@ -5,7 +5,7 @@
- `cmd/byre`: `byre serve` runs the server; `byre run` drives one Claude session in the current directory from the terminal.
- `api/openapi.yaml` is the API source of truth. `go generate ./internal/api` regenerates `internal/api/api.gen.go`, whose types are also the store's model types.
- `internal/store`: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers in `seq` order.
- `internal/server`: HTTP handlers, identity, SSE.
- `internal/server`: HTTP handlers, identity, SSE, the worktree file browser (see "Files").
- `internal/git`: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges and pushes.
- `internal/agents`: the agent session manager. It owns the running Claude processes and records their activity as events.
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
@@ -154,6 +154,12 @@ Answers reach the agent as one message, listing each answer under the question o
The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top.
## Files
The Files tab browses the selected task's worktree, read-only (`internal/server/files.go`). `GET /api/tasks/{taskId}/dir?path=` lists one directory, directories first and then by name, without entries named `.git`; `GET /api/tasks/{taskId}/file?path=` returns one regular file. Both take a slash-separated path relative to the worktree root and work only while the task is active (409 otherwise). Every path is opened through an `os.Root` on the worktree, so a path that leaves it, through `..`, an absolute path or a symlink at any depth, is a 400; a path with a `.git` component is also refused. A listing reports each entry as `dir`, `file` (with its size) or `other`, following symlinks that stay inside the worktree (`symlink` marks them); a symlink that leaves the worktree or points nowhere is `other` and can't be opened. Gitignored entries are listed with `ignored` set: one `git check-ignore --stdin` per listing (`git.Ignored`) over the entries' paths, taken from the directory's real path so a listing reached through an in-worktree symlink is checked where it really lives; untracked paths under an ignored directory count as ignored, tracked files never do. The view shows them dimmed with `ignored` beside them, and they open like anything else. A listing stops at `maxDirEntries` entries with `truncated` set. A file read sends at most `maxFileBytes`, cut back to a whole UTF-8 character, with `truncated` set when the file is longer; a file with a NUL byte or invalid UTF-8 in that part is `binary` and comes back with no text.
The view shows a breadcrumb from `worktree` down to the current directory or file, the directory's entries (a `../` row to go up), or the file's text with line numbers and a note when only its start is shown. Where you are is kept per task in memory, so switching tasks or tabs returns to it, but a page reload starts at the root. It refetches what it shows 1.5s after the selected task's last tool result or turn end, keeping the scroll position.
## Snapshots
byre snapshots each task's worktree as it changes (`internal/agents/snapshots.go`, `internal/git/snapshot.go`). A snapshot builds the tree the same way as the Changes view (a copy of the real index, `git add --all`, `git write-tree`), commits it with `git commit-tree` whose parent is the worktree's `HEAD`, authored as the session's creator with the message `byre snapshot <n> (<trigger>)`, and points `refs/byre/tasks/<task-id>/snap/<n>` at it (`git update-ref` with an empty old value, so an existing ref is never overwritten). A row in the `snapshots` table records id, task, session, `n` (from 1 per task), commit and tree shas, `event_seq` (the task's latest event seq read before the tree was built, so the tree holds at least the work up to that event), the trigger and the time, and the same transaction records a `snapshot.created` event carrying the row.
+290 -116
View File
@@ -129,6 +129,27 @@ func (e AttachmentKind) Valid() bool {
}
}
// Defines values for DirEntryType.
const (
DirEntryTypeDir DirEntryType = "dir"
DirEntryTypeFile DirEntryType = "file"
DirEntryTypeOther DirEntryType = "other"
)
// Valid indicates whether the value is a known member of the DirEntryType enum.
func (e DirEntryType) Valid() bool {
switch e {
case DirEntryTypeDir:
return true
case DirEntryTypeFile:
return true
case DirEntryTypeOther:
return true
default:
return false
}
}
// Defines values for FileChangeStatus.
const (
Added FileChangeStatus = "added"
@@ -400,6 +421,34 @@ type Caps struct {
PerVendor map[string]int `json:"per_vendor"`
}
// DirEntry defines model for DirEntry.
type DirEntry struct {
// Ignored git ignores the entry: .gitignore and the other exclude files match it or a directory above it.
Ignored bool `json:"ignored"`
Name string `json:"name"`
// Size file: its size in bytes.
Size int64 `json:"size"`
Symlink bool `json:"symlink"`
// Type What the entry is, following a symlink that stays in the worktree. other: a symlink that leaves the worktree or points nowhere, or a device, socket or pipe.
Type DirEntryType `json:"type"`
}
// DirEntryType What the entry is, following a symlink that stays in the worktree. other: a symlink that leaves the worktree or points nowhere, or a device, socket or pipe.
type DirEntryType string
// DirListing defines model for DirListing.
type DirListing struct {
Entries []DirEntry `json:"entries"`
// Path The directory, relative to the worktree root; empty for the root.
Path string `json:"path"`
// Truncated The directory has more entries than were returned.
Truncated bool `json:"truncated"`
}
// Error defines model for Error.
type Error struct {
Message string `json:"message"`
@@ -432,6 +481,18 @@ type FileChange struct {
// FileChangeStatus defines model for FileChange.Status.
type FileChangeStatus string
// FileContent defines model for FileContent.
type FileContent struct {
// Binary The file isn't UTF-8 text; text is empty.
Binary bool `json:"binary"`
Path string `json:"path"`
Size int64 `json:"size"`
Text string `json:"text"`
// Truncated text holds only the start of the file.
Truncated bool `json:"truncated"`
}
// FileDiff defines model for FileDiff.
type FileDiff struct {
Patch string `json:"patch"`
@@ -819,12 +880,24 @@ type GetTaskDiffParams struct {
OldPath *string `form:"old_path,omitempty" json:"old_path,omitempty"`
}
// GetTaskDirParams defines parameters for GetTaskDir.
type GetTaskDirParams struct {
// Path Slash-separated, relative to the worktree root; empty or absent for the root.
Path *string `form:"path,omitempty" json:"path,omitempty"`
}
// ListTaskEventsParams defines parameters for ListTaskEvents.
type ListTaskEventsParams struct {
After *int64 `form:"after,omitempty" json:"after,omitempty"`
Limit *int `form:"limit,omitempty" json:"limit,omitempty"`
}
// GetTaskFileParams defines parameters for GetTaskFile.
type GetTaskFileParams struct {
// Path Slash-separated, relative to the worktree root.
Path string `form:"path" json:"path"`
}
// GetTaskImageParams defines parameters for GetTaskImage.
type GetTaskImageParams struct {
Path string `form:"path" json:"path"`
@@ -944,12 +1017,18 @@ type ServerInterface interface {
// (GET /api/tasks/{taskId}/diff)
GetTaskDiff(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskDiffParams)
// (GET /api/tasks/{taskId}/dir)
GetTaskDir(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskDirParams)
// (POST /api/tasks/{taskId}/discard)
DiscardTask(w http.ResponseWriter, r *http.Request, taskId string)
// (GET /api/tasks/{taskId}/events)
ListTaskEvents(w http.ResponseWriter, r *http.Request, taskId string, params ListTaskEventsParams)
// (GET /api/tasks/{taskId}/file)
GetTaskFile(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskFileParams)
// (GET /api/tasks/{taskId}/image)
GetTaskImage(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskImageParams)
@@ -1767,6 +1846,48 @@ func (siw *ServerInterfaceWrapper) GetTaskDiff(w http.ResponseWriter, r *http.Re
handler.ServeHTTP(w, r)
}
// GetTaskDir operation middleware
func (siw *ServerInterfaceWrapper) GetTaskDir(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
// Parameter object where we will unmarshal all parameters from the context
var params GetTaskDirParams
// ------------- Optional query parameter "path" -------------
err = runtime.BindQueryParameterWithOptions("form", true, false, "path", r.URL.Query(), &params.Path, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err})
}
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetTaskDir(w, r, taskId, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// DiscardTask operation middleware
func (siw *ServerInterfaceWrapper) DiscardTask(w http.ResponseWriter, r *http.Request) {
@@ -1848,6 +1969,48 @@ func (siw *ServerInterfaceWrapper) ListTaskEvents(w http.ResponseWriter, r *http
handler.ServeHTTP(w, r)
}
// GetTaskFile operation middleware
func (siw *ServerInterfaceWrapper) GetTaskFile(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
// Parameter object where we will unmarshal all parameters from the context
var params GetTaskFileParams
// ------------- Required query parameter "path" -------------
err = runtime.BindQueryParameterWithOptions("form", true, true, "path", r.URL.Query(), &params.Path, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err})
}
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetTaskFile(w, r, taskId, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetTaskImage operation middleware
func (siw *ServerInterfaceWrapper) GetTaskImage(w http.ResponseWriter, r *http.Request) {
@@ -2189,6 +2352,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/changes", wrapper.GetTaskChanges)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/image", wrapper.GetTaskImage)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/diff", wrapper.GetTaskDiff)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/dir", wrapper.GetTaskDir)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/file", wrapper.GetTaskFile)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots", wrapper.ListSnapshots)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
@@ -2212,122 +2377,131 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"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",
"9Zb6++EEwcm/rTd5d/ffAQAA//8=",
"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",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+37
View File
@@ -0,0 +1,37 @@
package git
import (
"bytes"
"errors"
"fmt"
"os/exec"
"strings"
)
func Ignored(worktree string, paths []string) (map[string]bool, error) {
ignored := map[string]bool{}
if len(paths) == 0 {
return ignored, nil
}
cmd := exec.Command("git", "check-ignore", "--stdin", "-z")
cmd.Dir = worktree
cmd.Stdin = strings.NewReader(strings.Join(paths, "\x00") + "\x00")
stderr := &strings.Builder{}
cmd.Stderr = stderr
out, err := cmd.Output()
exit := &exec.ExitError{}
if errors.As(err, &exit) && exit.ExitCode() == 1 {
return ignored, nil
}
if err != nil {
return nil, fmt.Errorf("git check-ignore: %w: %s", err, strings.TrimSpace(stderr.String()))
}
for _, p := range bytes.Split(out, []byte{0}) {
if len(p) > 0 {
ignored[string(p)] = true
}
}
return ignored, nil
}
+241
View File
@@ -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)
}
+122
View File
@@ -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)
}
}
+1
View File
@@ -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",
+199
View File
@@ -0,0 +1,199 @@
import { get } from "./api.js";
import { errorText, h, replace } from "./dom.js";
import { onEach, state } from "./state.js";
const places = new Map();
function join(dir, name) {
return dir ? `${dir}/${name}` : name;
}
function parent(path) {
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
}
function bytes(n) {
if (n < 1024) {
return `${n} B`;
}
if (n < 1024 * 1024) {
return `${(n / 1024).toFixed(1)} KiB`;
}
return `${(n / 1024 / 1024).toFixed(1)} MiB`;
}
function shown(c) {
return new TextEncoder().encode(c.text).length;
}
export function mountFiles(root) {
const head = h("header", { class: "files-header" });
const body = h("div", { class: "files-body" });
let listing;
let content;
let error = "";
let target = 0;
let timer;
let disposed = false;
const place = () => {
const taskId = state.selectedTaskId;
return (taskId && places.get(taskId)) || { dir: "" };
};
const go = (p) => {
const taskId = state.selectedTaskId;
if (!taskId) {
return;
}
places.set(taskId, p);
listing = undefined;
content = undefined;
error = "";
render();
body.scrollTop = 0;
void load();
};
const load = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
render();
return;
}
const started = ++target;
const p = place();
try {
if (p.file !== undefined) {
const fresh = await get("/api/tasks/{taskId}/file", { path: { taskId }, query: { path: p.file } });
if (started === target) {
content = fresh;
listing = undefined;
}
}
else {
const fresh = await get("/api/tasks/{taskId}/dir", { path: { taskId }, query: { path: p.dir } });
if (started === target) {
listing = fresh;
content = undefined;
}
}
if (started === target) {
error = "";
}
}
catch (err) {
if (started === target) {
error = errorText(err);
listing = undefined;
content = undefined;
}
}
if (disposed || started !== target) {
return;
}
const scroll = body.scrollTop;
render();
body.scrollTop = scroll;
};
const soon = () => {
clearTimeout(timer);
timer = setTimeout(() => void load(), 1500);
};
const crumbs = () => {
const p = place();
const path = p.file ?? p.dir;
const parts = path ? path.split("/") : [];
const link = (label, dir, last) => {
if (last) {
return h("span", { class: "crumb current" }, label);
}
const b = h("button", { type: "button", class: "crumb" }, label);
b.onclick = () => go({ dir });
return b;
};
const items = [link("worktree", "", parts.length === 0)];
parts.forEach((part, i) => {
items.push("/");
items.push(link(part, parts.slice(0, i + 1).join("/"), i === parts.length - 1));
});
return h("nav", { class: "crumbs" }, ...items);
};
const summary = () => {
if (error) {
return "";
}
if (content) {
if (content.binary) {
return `${bytes(content.size)} · binary`;
}
return content.truncated ? `${bytes(content.size)} · showing the first ${bytes(shown(content))}` : bytes(content.size);
}
if (listing) {
return `${listing.entries.length}${listing.truncated ? "+" : ""} ${listing.entries.length === 1 ? "entry" : "entries"}`;
}
return state.selectedTaskId ? "Loading…" : "";
};
const entryRow = (dir, e) => {
const label = e.type === "dir" ? `${e.name}/` : e.name;
const row = h(e.type === "other" ? "div" : "button", { class: `entry ${e.type}${e.ignored ? " ignored" : ""}`, title: e.type === "other" ? "Leaves the worktree, points nowhere, or isn't a file or directory" : "" }, h("span", { class: "entry-name" }, label, e.symlink ? h("span", { class: "symlink" }, " →") : null), h("span", { class: "entry-meta" }, e.ignored ? "ignored" : null, e.ignored && e.type === "file" ? " · " : null, e.type === "file" ? bytes(e.size) : null));
if (e.type === "dir") {
row.onclick = () => go({ dir: join(dir, e.name) });
}
else if (e.type === "file") {
row.onclick = () => go({ dir, file: join(dir, e.name) });
}
return row;
};
const render = () => {
replace(head, crumbs(), h("span", { class: "files-summary" }, summary()));
if (!state.selectedTaskId) {
replace(body, h("div", { class: "empty" }, "No task selected."));
return;
}
if (error) {
replace(body, h("div", { class: "empty error-text" }, error));
return;
}
if (content) {
if (content.binary) {
replace(body, h("div", { class: "empty" }, "Binary file."));
return;
}
const lines = content.text.split("\n");
if (lines.at(-1) === "") {
lines.pop();
}
replace(body, h("pre", { class: "file-text" }, ...lines.map((l) => h("span", { class: "line" }, l || " "))), content.truncated ? h("div", { class: "hint file-cut" }, `Showing the first ${bytes(shown(content))} of ${bytes(content.size)}.`) : null);
return;
}
if (!listing) {
replace(body, h("div", { class: "empty" }, "Loading…"));
return;
}
const l = listing;
const up = l.path ? h("button", { type: "button", class: "entry dir" }, h("span", { class: "entry-name" }, "../")) : null;
if (up) {
up.onclick = () => go({ dir: parent(l.path) });
}
replace(body, h("div", { class: "entries" }, up, ...l.entries.map((e) => entryRow(l.path, e)), l.entries.length === 0 ? h("div", { class: "empty" }, "Empty directory.") : null, l.truncated ? h("div", { class: "hint" }, `Only the first ${l.entries.length} entries are shown.`) : null));
};
const offs = [
onEach(["selection"], () => {
target++;
clearTimeout(timer);
listing = undefined;
content = undefined;
error = "";
render();
void load();
}),
onEach(["transcript"], (fresh) => {
if (fresh.some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
soon();
}
}),
];
replace(root, h("div", { class: "files-view" }, head, body));
render();
void load();
return () => {
disposed = true;
clearTimeout(timer);
for (const off of offs) {
off();
}
};
}
//# sourceMappingURL=files.js.map
+1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,6 +1,7 @@
import { mountAdmin } from "./admin.js";
import { mountChanges } from "./changes.js";
import { h, replace } from "./dom.js";
import { mountFiles } from "./files.js";
import { mountInbox } from "./inbox.js";
import { mountSettings } from "./settings.js";
import { mountTranscript } from "./task.js";
@@ -9,13 +10,14 @@ import { mountTodo } from "./todo.js";
const views = {
transcript: { label: "Transcript", mount: mountTranscript },
changes: { label: "Changes", mount: (root) => mountChanges(root) },
files: { label: "Files", mount: mountFiles },
tasks: { label: "Tasks", mount: mountTasks },
todo: { label: "Todo", mount: mountTodo },
inbox: { label: "Inbox", mount: mountInbox },
admin: { label: "Admin", mount: mountAdmin },
settings: { label: "Settings", mount: mountSettings },
};
const order = ["transcript", "changes", "tasks", "todo", "inbox", "admin", "settings"];
const order = ["transcript", "changes", "files", "tasks", "todo", "inbox", "admin", "settings"];
const panes = [];
export function openTranscript() {
if (panes.some((p) => p.current() === "transcript")) {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"}
+127
View File
@@ -350,6 +350,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/dir": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
/** @description One directory of the task's worktree, directories first, then by name. Entries named .git are left out; gitignored entries are included and marked. A path that leaves the worktree, through .. or a symlink, is refused. */
get: operations["getTaskDir"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/file": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
/** @description A regular file in the task's worktree. Text is returned up to a size limit; binary files return no content. A path that leaves the worktree, through .. or a symlink, is refused. */
get: operations["getTaskFile"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/snapshots": {
parameters: {
query?: never;
@@ -722,6 +760,39 @@ export interface components {
path: string;
patch: string;
};
DirListing: {
/** @description The directory, relative to the worktree root; empty for the root. */
path: string;
entries: components["schemas"]["DirEntry"][];
/** @description The directory has more entries than were returned. */
truncated: boolean;
};
DirEntry: {
name: string;
/** @description git ignores the entry: .gitignore and the other exclude files match it or a directory above it. */
ignored: boolean;
/**
* @description What the entry is, following a symlink that stays in the worktree. other: a symlink that leaves the worktree or points nowhere, or a device, socket or pipe.
* @enum {string}
*/
type: "dir" | "file" | "other";
symlink: boolean;
/**
* Format: int64
* @description file: its size in bytes.
*/
size: number;
};
FileContent: {
path: string;
/** Format: int64 */
size: number;
/** @description The file isn't UTF-8 text; text is empty. */
binary: boolean;
/** @description text holds only the start of the file. */
truncated: boolean;
text: string;
};
Snapshot: {
id: string;
task_id: string;
@@ -1535,6 +1606,62 @@ export interface operations {
409: components["responses"]["Error"];
};
};
getTaskDir: {
parameters: {
query?: {
/** @description Slash-separated, relative to the worktree root; empty or absent for the root. */
path?: string;
};
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The directory's entries. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DirListing"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
409: components["responses"]["Error"];
};
};
getTaskFile: {
parameters: {
query: {
/** @description Slash-separated, relative to the worktree root. */
path: string;
};
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The file. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["FileContent"];
};
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
409: components["responses"]["Error"];
};
};
listSnapshots: {
parameters: {
query?: never;
+236
View File
@@ -0,0 +1,236 @@
import { get, type ByreEvent, type Schemas } from "./api.js";
import { errorText, h, replace } from "./dom.js";
import { onEach, state } from "./state.js";
type Listing = Schemas["DirListing"];
type Entry = Schemas["DirEntry"];
type Content = Schemas["FileContent"];
type Place = { dir: string; file?: string };
const places = new Map<string, Place>();
function join(dir: string, name: string): string {
return dir ? `${dir}/${name}` : name;
}
function parent(path: string): string {
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
}
function bytes(n: number): string {
if (n < 1024) {
return `${n} B`;
}
if (n < 1024 * 1024) {
return `${(n / 1024).toFixed(1)} KiB`;
}
return `${(n / 1024 / 1024).toFixed(1)} MiB`;
}
function shown(c: Content): number {
return new TextEncoder().encode(c.text).length;
}
export function mountFiles(root: HTMLElement): () => void {
const head = h("header", { class: "files-header" });
const body = h("div", { class: "files-body" });
let listing: Listing | undefined;
let content: Content | undefined;
let error = "";
let target = 0;
let timer: number | undefined;
let disposed = false;
const place = (): Place => {
const taskId = state.selectedTaskId;
return (taskId && places.get(taskId)) || { dir: "" };
};
const go = (p: Place) => {
const taskId = state.selectedTaskId;
if (!taskId) {
return;
}
places.set(taskId, p);
listing = undefined;
content = undefined;
error = "";
render();
body.scrollTop = 0;
void load();
};
const load = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
render();
return;
}
const started = ++target;
const p = place();
try {
if (p.file !== undefined) {
const fresh = await get("/api/tasks/{taskId}/file", { path: { taskId }, query: { path: p.file } });
if (started === target) {
content = fresh;
listing = undefined;
}
} else {
const fresh = await get("/api/tasks/{taskId}/dir", { path: { taskId }, query: { path: p.dir } });
if (started === target) {
listing = fresh;
content = undefined;
}
}
if (started === target) {
error = "";
}
} catch (err) {
if (started === target) {
error = errorText(err);
listing = undefined;
content = undefined;
}
}
if (disposed || started !== target) {
return;
}
const scroll = body.scrollTop;
render();
body.scrollTop = scroll;
};
const soon = () => {
clearTimeout(timer);
timer = setTimeout(() => void load(), 1500);
};
const crumbs = (): HTMLElement => {
const p = place();
const path = p.file ?? p.dir;
const parts = path ? path.split("/") : [];
const link = (label: string, dir: string, last: boolean) => {
if (last) {
return h("span", { class: "crumb current" }, label);
}
const b = h("button", { type: "button", class: "crumb" }, label);
b.onclick = () => go({ dir });
return b;
};
const items: (HTMLElement | string)[] = [link("worktree", "", parts.length === 0)];
parts.forEach((part, i) => {
items.push("/");
items.push(link(part, parts.slice(0, i + 1).join("/"), i === parts.length - 1));
});
return h("nav", { class: "crumbs" }, ...items);
};
const summary = (): string => {
if (error) {
return "";
}
if (content) {
if (content.binary) {
return `${bytes(content.size)} · binary`;
}
return content.truncated ? `${bytes(content.size)} · showing the first ${bytes(shown(content))}` : bytes(content.size);
}
if (listing) {
return `${listing.entries.length}${listing.truncated ? "+" : ""} ${listing.entries.length === 1 ? "entry" : "entries"}`;
}
return state.selectedTaskId ? "Loading…" : "";
};
const entryRow = (dir: string, e: Entry): HTMLElement => {
const label = e.type === "dir" ? `${e.name}/` : e.name;
const row = h(
e.type === "other" ? "div" : "button",
{ class: `entry ${e.type}${e.ignored ? " ignored" : ""}`, title: e.type === "other" ? "Leaves the worktree, points nowhere, or isn't a file or directory" : "" },
h("span", { class: "entry-name" }, label, e.symlink ? h("span", { class: "symlink" }, " →") : null),
h("span", { class: "entry-meta" }, e.ignored ? "ignored" : null, e.ignored && e.type === "file" ? " · " : null, e.type === "file" ? bytes(e.size) : null),
);
if (e.type === "dir") {
row.onclick = () => go({ dir: join(dir, e.name) });
} else if (e.type === "file") {
row.onclick = () => go({ dir, file: join(dir, e.name) });
}
return row;
};
const render = () => {
replace(head, crumbs(), h("span", { class: "files-summary" }, summary()));
if (!state.selectedTaskId) {
replace(body, h("div", { class: "empty" }, "No task selected."));
return;
}
if (error) {
replace(body, h("div", { class: "empty error-text" }, error));
return;
}
if (content) {
if (content.binary) {
replace(body, h("div", { class: "empty" }, "Binary file."));
return;
}
const lines = content.text.split("\n");
if (lines.at(-1) === "") {
lines.pop();
}
replace(
body,
h("pre", { class: "file-text" }, ...lines.map((l) => h("span", { class: "line" }, l || " "))),
content.truncated ? h("div", { class: "hint file-cut" }, `Showing the first ${bytes(shown(content))} of ${bytes(content.size)}.`) : null,
);
return;
}
if (!listing) {
replace(body, h("div", { class: "empty" }, "Loading…"));
return;
}
const l = listing;
const up = l.path ? h("button", { type: "button", class: "entry dir" }, h("span", { class: "entry-name" }, "../")) : null;
if (up) {
up.onclick = () => go({ dir: parent(l.path) });
}
replace(
body,
h(
"div",
{ class: "entries" },
up,
...l.entries.map((e) => entryRow(l.path, e)),
l.entries.length === 0 ? h("div", { class: "empty" }, "Empty directory.") : null,
l.truncated ? h("div", { class: "hint" }, `Only the first ${l.entries.length} entries are shown.`) : null,
),
);
};
const offs = [
onEach(["selection"], () => {
target++;
clearTimeout(timer);
listing = undefined;
content = undefined;
error = "";
render();
void load();
}),
onEach(["transcript"], (fresh) => {
if ((fresh as ByreEvent[]).some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
soon();
}
}),
];
replace(root, h("div", { class: "files-view" }, head, body));
render();
void load();
return () => {
disposed = true;
clearTimeout(timer);
for (const off of offs) {
off();
}
};
}
+4 -2
View File
@@ -1,17 +1,19 @@
import { mountAdmin } from "./admin.js";
import { mountChanges } from "./changes.js";
import { h, replace } from "./dom.js";
import { mountFiles } from "./files.js";
import { mountInbox } from "./inbox.js";
import { mountSettings } from "./settings.js";
import { mountTranscript } from "./task.js";
import { mountTasks } from "./tasks.js";
import { mountTodo } from "./todo.js";
export type View = "transcript" | "changes" | "tasks" | "todo" | "inbox" | "admin" | "settings";
export type View = "transcript" | "changes" | "files" | "tasks" | "todo" | "inbox" | "admin" | "settings";
const views: Record<View, { label: string; mount: (root: HTMLElement) => () => void }> = {
transcript: { label: "Transcript", mount: mountTranscript },
changes: { label: "Changes", mount: (root) => mountChanges(root) },
files: { label: "Files", mount: mountFiles },
tasks: { label: "Tasks", mount: mountTasks },
todo: { label: "Todo", mount: mountTodo },
inbox: { label: "Inbox", mount: mountInbox },
@@ -19,7 +21,7 @@ const views: Record<View, { label: string; mount: (root: HTMLElement) => () => v
settings: { label: "Settings", mount: mountSettings },
};
const order: View[] = ["transcript", "changes", "tasks", "todo", "inbox", "admin", "settings"];
const order: View[] = ["transcript", "changes", "files", "tasks", "todo", "inbox", "admin", "settings"];
const panes: Pane[] = [];
+116
View File
@@ -315,6 +315,7 @@ textarea {
.view,
.transcript-view,
.changes-view,
.files-view,
.inbox-view {
flex: 1;
min-height: 0;
@@ -1082,6 +1083,121 @@ pre.diff .meta {
color: var(--muted);
}
.files-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
}
.crumbs {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px;
min-width: 0;
color: var(--muted);
}
.crumb {
padding: 0 2px;
border: none;
background: none;
color: var(--accent);
}
.crumb.current {
color: var(--text);
}
.files-summary {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
.files-body {
flex: 1;
overflow: auto;
}
.entries {
display: flex;
flex-direction: column;
padding: 6px 8px 16px;
}
.entry {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 2px 8px;
border: none;
border-radius: 4px;
background: none;
color: var(--text);
text-align: left;
}
button.entry:hover {
background: var(--surface);
}
.entry.dir .entry-name {
color: var(--accent);
}
.entry.other {
color: var(--muted);
}
.entry .symlink,
.entry-meta {
color: var(--muted);
}
.entry-meta {
font-size: 11px;
white-space: nowrap;
}
.entry.ignored .entry-name {
color: var(--muted);
}
pre.file-text {
white-space: pre;
word-break: normal;
padding: 6px 0;
font-size: 11px;
counter-reset: line;
}
pre.file-text .line {
display: block;
min-width: max-content;
padding-right: 10px;
counter-increment: line;
}
pre.file-text .line::before {
content: counter(line);
display: inline-block;
width: 6ch;
margin-right: 12px;
padding-right: 6px;
text-align: right;
color: var(--muted);
border-right: 1px solid var(--border);
user-select: none;
}
.file-cut {
padding: 6px 16px 16px;
}
.scope-tabs {
display: flex;
gap: 4px;