diff --git a/TODO.md b/TODO.md index 85cae37..9adaa17 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/api/openapi.yaml b/api/openapi.yaml index 1797463..ebba97a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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] diff --git a/docs/operations.md b/docs/operations.md index f20182c..e6243ae 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 ()`, and points `refs/byre/tasks//snap/` 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. diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index f9eecdb..2b4c2ce 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -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(), ¶ms.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(), ¶ms.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{ - "7H1tc9s21uhfwfDeGcf30nLapp25zic3ybbe3bTZOt3O3D4dLUQeSViTgAKAVrQZ//dncA5AgiIoyWrs", - "ZLfPl0QmCfDgvOG8gh+yQtUrJUFak118yDSYlZIG8I9XWivtfhRKWpDW/eSrVSUKboWS5/80SrprplhC", - "zd2v/61hnl1k/+u8m/Wc7ppzmu3u7i7PSjCFFis3SXZBr5lk7oZ/1k11uQBp32hVgMG/V1qtQFtBoC2B", - "V3bpfvXnMrYpblgNXBrGmW20ZMIw3Ugp5IJxWTKpmLEaeM3gFqRlS24Y11rcQsnmSjOcYcrnFvTUQKFk", - "aZ4zeC8slH5euwS2IsDc5AslYZLlGcimzi5+zdRNlhMc7iIOzH7LM7tZAQKohVxkefb+zA04u+Va8tot", - "6tfse1zUj24c/bz2s9Bfr/xcd3lWcWOnCP+U2yEWflmC7IHpnmdrrSwwziohgVnFhDUeFVmezZWu3VRZ", - "yS2cWVFDlmdKs7WbSlhmLNcWykl2IZuq2l7OXZ6tROkA8deFtLAA7W4YMEYoOe3d78YZyy0gZxmmJEOy", - "X9OQ9mX91b1dAvOTnrgVuPEJgCw3N2MvvQVZEmtv3brLMw3vGqGhdBSJYO8mbIfTosMStqmSBybtiK9m", - "/4TCOgDiZQ65u9DALZSetknSDJYUxsw2yRWPIKJWJVRDBvoJ3jVgkOXdA88Z1Cu7ififMHBiWAlz3lR2", - "kgJJqwopGyRD1KsKapAW9FAiYlboA/OugaYVvk6o11xYJ9ROaDmbawBmKmVZI0vQCGShZNFoDbLYsIKv", - "TCyloqwcGmnuLM+8ishyVIgVHC61MSmv3QquaOrB9b+Fdw3u/NS+fHDrRQfNwTwd1lhUvCkhiWp6eLpT", - "NrdkYUwGiIVSU3aSgZzQ49E8ZvKkhFjLi2Xtt50t+eCeNxJoAFlOnYZLKyNR80WCxfDyBSu55eznn/6a", - "5OYbIcsYu3OBayrFfJ6Fid2ffKFRozpUORRIvjJL5fRBo6skMWrQNScS9MHyk12w1/QEM6rRBSTBW/HU", - "fuiAvGAaVupMQ8WtuAXmnpwwv2Qu6RdzTzJB24YD/cSwtdI3VgPkjM+MqhoLTGnWzoNbSFrwnRUwXMx8", - "fsEWwjINt8LxCNNcLtKrCVjbsWtou4POuwTFkWEvtyO1U3z5baVmCY4kC2lKj38YV88PrdJTQtsD7n5S", - "+IKvEtbXolIzntg2XitjUT9HJpdlShbAeKGVMYxXld87TET3iHAr0NNOk/GyFG5uXr3pATAcdzAgK9Ae", - "gpzdwAZKNtv4CxFEAQNb6PQL74GZQltrOffxVoMxXv/sJlt4MDn3bVIn8sImLZo8425Pme4xw47hz6AR", - "E7poUyke3+vAX2nlfo4KNrzrASCk/eZZklPGRXxgxL3LPLC5R1MH4l4J+JOo4MXSKaoEzj17jvDkTEiu", - "Y9mdKVUBl8SvFewYqqpymtboGpzlUV6Qfc/tks1grjTg33Rz5/6QtLoaE+9svCzRTqlVKeYCfyK4ZCjR", - "+/0rpgWiJmErbVMB39++LY9wFyOjRdoYKV667XZAiBW3xXKEFZPLTgNH06RefSVn6v2VhTrBBIeI16xS", - "xY37nWSGY4Rv5EXbVgra8Y5z3IJXSjs7JMAKqKXyTHML00rUaVe1L88D0xxnv2C4x7y37Mlrrm9KtZZs", - "LeySzPClEgUYJiR6ntywf5yxJ+yUuQH/YOf45/8Jf65EcXOmJJoa/zhjv7Lf4ud+ff9b/ByXG1YJY5mw", - "UJvTnAWAcrZebqaIdShzphBgw54AL5YeoBODM+XO6FG6BH2as7qprFhVwJ5Y3QC6FuE9pznTUKi6BllC", - "yZ64ldVc30AZFniC853mjLemq5kwwvoFI+y4UXMhecW8imdqHnwau+SW0ezSsUBVbU4nrEesC4b/TVhM", - "sot2P+uu0ptaL83dOKMbJICnuZ/pv2SWp5S0UFrYTcxJHplZni28UQ+3AtZZns03Is02u1W9BqOq23uy", - "fTtoxCjqBbCQK/bFpVC0f/LDcGOhObnWfNNzS/fOgh7bPvPTLjXw8nCXK8Ji7H8N1E48c7vdtYSMVFC8", - "+QUHLdJAMQ5HVSHhqxxqw9+J/xcIBzpFQl7R8C+2SbKFpYPBNaktvJPVgyGOXNMEuxyjzEuoxC3oj7MF", - "uDWMMZ9XhL2lDjl0a0VOde148B7kfesGOBfMgD6Q/cNq/CD/0rxHuL1GXIrHHoEVDqREf0+lGxdh6zQg", - "w/b1nMF7Xthqw9z+SHGvdrvsdj6/edbOKIwecntleGjiEXs/+m/BaZegCUzc2wxIy6zCC6icJh5upmQK", - "hgt/m1fReF4puYhNB3B7TkBA0rA9kgm3ea1lM5xvLxO99W9to/4rb2UhWjAQZGphTHJjjDaL3hTgJuDS", - "rJ0q6ObA36ZZgTZQjphor8mcGONtjPEkmO3bSs2YKA2ba1WzZuW2BXctH5LTGW40DVHJiBJa6t+PoUai", - "zq/dZWRZuxSGjCIuS1ZxC9qxtZmwX3w+ohTzOWgPt+1lBHD2PL7IzFrYYgkm9pWCASYMrjXNW0nOf803", - "bAY+Jk4JEo8XDWwhbkEmJtviN5w5xWU/KCvmPsP2RsPcpNI7olgyCWsmHCOR8esWURLinKJ0EteYJZPR", - "dBOyJiK2CDbBNAhl4m1/C7cYN04WUTjDwAvmDOUJ+1Gy2SaRDIicHLQ3E/P3DFxaS3qCHTD+oORZAKlV", - "MCPTdEZyYqLYrt4FC1n1iQneOqY1TV1zLSA5eIsREiSIl9q9qsVgfwkpHnrDtRWFWPFUoKgUZlXxzdS5", - "8UlpXQg7hZqLavRuGLqVx+3kwckXcuGSG3nidLtlHGO/ogRphd08d89s3D+SLcAyqZjVXFSgk4JYqYWQ", - "+y0GeizvrzGCOV5cEnFkaz9KRs4Ly3SmuRwJXoyYDqOU01ArO3ZrpaaHBUNwF/To6oYNAG5fd7+Iskfx", - "2Ea1Y20HL2AA+wgcVhWqGgnV3ssn5WvkkcaGtPpcaGPZM/YX8e2OJHYIh2goQNy2MbWtZXMzkunal1gX", - "/0oIaXjviWHzpqpYBXJhl0w4/W1hJCh/aLq8zQn23FLvU+I6CFkeuL1kMalES+Pt82NzA9dU/eGQQLls", - "XvKVMzAK1VSl01Yrrg2a0KXAvzUUaiGFAWaELIDNNhpCQcQhSQS3+sIH7YfUqJXBVzgTy9k4udvZwVhi", - "op5htcvE7fPz3R6f2aOxhSxJisYs/wKbRLS1mVWimN7QvS2Dkhv45lmjq2yYPG+kg1uDs2jZm7Mvv/6G", - "rZRwpheKyCAo271mDLzrZha9fCDDDWmLWYBpV0FJNNOJYW4oM1BosCmpBFki5OmQ85dff1Me+2Ia7Jhq", - "rwnZAtG+Mqclp7Dlawo+hX+w0qpeHeshpIKK9coeENKn55K4CCn542L5haprYadmyYf4umR0l6k5sxrA", - "PcXWzot3WsVhSZDSCbn1E8O+f3X5sq2zWnPDLL9JuhDHxZaoIMnn1Yb853P9zscy1tfEtTZcSMMjWF7j", - "eZdfA7ClqkrDuGUVcGPbdbFm1RUHHJDJGzNzhgC/UQZ1/lahQgDT5MSnXzz32bA5Vv/B3Jw7nX3uHjfn", - "H3wQ9e7cjTv/IO8m90ww5lmg7chNsVhArxTHKlVNC16hWmy0nAJlI83NdNnUHIPwok7VRSVrjgIHv6U3", - "vVWqekGTb99ptHyFr9q6cWluvvcv3h5CcOwp/0mEn2XWk40ISTETdujZaypet5nJQapb3MIUqZne8Qk6", - "X/2Y0muvbkFvvN4KRZK4rTvx1Bw9spyJsmpjCIYJWVRNCeXBW3KvhDUVKfbFFbvmwAIMZ8PMpmigHZgc", - "j0oRD8+oVw6riJMExi5jXIFhVFlnq02osBip5yBzf4ROK2+0TLvgwME2jvExgQbKKZZ6pJ3xfqGgVm4b", - "6yoE+crkQZlgEpDc0rWoKtJ3BxPbvesnmKfovBVx2GO4dvyfsNxJSw/SeQzzmmSA+ozjwaYpUW83CkMR", - "jVTrnKmqdECE2Nr9zNQdaDIGpkUlAvslKolpA7rX7pco5x4u8nu1Zhh45u1KMfhY8w1bKIx4qcYy3q8c", - "97FEYRlZ0wz3yKa4SUvCv1Q9EylddB0UjE8nBxAw0CYVq7BojwQvx4Ao35JF1FxLbrC4PZCF6H0QUf4/", - "grbXaYjwHymkgbKJhD7v6+o+jfsKZ5sTt2Tba8u+MA0VfYfmNOmHKie18bzl5ma47ThrfupLG4cFHuNR", - "nKJSpuXakUJ9hx80smrQCyjJ+TQF136/uV/E6RHqxh2YU9rth8uiRZChaN413Cxbs5iW60NJjLD2PA6l", - "2yVoQFRIZZcoioqwkrSICRArbDUS7MBbaI4PgHntxrKa34C56KhAAxppRRU5NwacgEvM7pBkGrDT6O3p", - "oqs9xW7D0isUl8yvy+eAiA0ONA0d75LhdBnm6i69DrN2l1528zv5D4gcGrWqVFPhK6D2oPntjy9/nNQU", - "QO+zt1chaKY/Z3yGXqTbmAmxqCaSmAz+0n1imP3KCVxZn2Fasd2eP++EPSpZu0+Y0yGYygZNWpOk0RjE", - "RJOD5XQ11z7pcuHTVnoBZ26GgG1ELi0Ed4ehgOUUuMpxXvTI4VaopnOcTgy+L4n5uajuUc0RlUumMska", - "Ugt3S/WLCXTAaMLc7bR6Q2pgDRR1W2BBl+75p+Pwb2dbHOY9HGFpo+QbiZWMych2hg8fS06uSjWcFt4L", - "Y5PZPiDZGJCVetdaaeMWY89aqZEMXEvAwyw1VSqseNxnFnjAw/xjK05XT85UufHdX8PKQTfhiXO8SpAW", - "U5hMQ61uoz60oQj5QfNGI9owzpu0DKEIYcOEOoP3NrCkBK6dwbsEXmIl+UzdQgtfYvMSbu+SaWnasVMF", - "wDF34MDOW8vT0RXrHNFE9FUXATs+o3ZAzjkoPYfzDgFjBLt2ujpRxD4amjt0F6ZtQXUO1tGC5J2JAYx7", - "IngP3xqYgvZn6Tf1RFKjTqejX9CNYDbFmh6rRbkGeTJiVaW9kD11562y78PhtLqhV1LhRVd3gWtiQa/2", - "J/Qh/1pYi2WyuB1GcRQK++eZkMYhYxSqaJZETJDbpSGdkX5bb19JYWWQG6m9d0HY6MHXL1aPAUuS3Ne5", - "PVgG/uBEeZ7FlSDTVags2bUDDEtRfk++PQnBGNJ+XpXJvfdozGw38hxaEeBd4yENwY5BsV2CH/xar8yD", - "t0oGqvvV3vE6JV1MfWR/9cFtMr5i2C9siIs7FNS5ikygbLbRgEpPUyNz9nTydPKUyhtB8pXILrKvJk8n", - "X1FzxRJRd85X4pyXtZDnnQO0ANxqHIqRP67K7CL7Dux1ML57hcVfPn360c4l8G9IHExwDfoWNKM2bjSt", - "G8OdH3qHD+M6ZpWakYWvTMITvrZKg4m6LJVmjcFGSx4q0CbM7ZBuUw7JIc3X9Pxz3PkdIdytEiwUwXvC", - "BzECg5DVjbFsBuzND9/l7M9vXn2Xs++u/uTs5F9g9mbCLi0lmr9mr8W3VPXfx/XPbW4vI74AY7/1xlmE", - "ZwTrfCHmfQS3sQbfvpPa02noP1ewOHbsSh49dA2z1X3H9gTE6gbuBmz4xUdjQ8R8ggkxR+y4qGSO1yZO", - "uJ4R+6ema+E7bwsBnn3x1cFPbzH2+Qf331V5t0tEW57ZKaB/MMYZEtFh8sSQyJJlj8LtUYRC7on77B7k", - "cnpV8xosaJNd/Pohc7t/6GejvTAjGmbb3JxHa9pewW+BDTCsG2volIo8wzAOPTphr3ixpD/QO3GaC48a", - "gXfPqcPeazlsZHV+/p+vf/xhQn8ahvFa9ldu7BleObt6yZ54d5/uvWtAb1i77FMsu9XgDBAnJBSVx6B5", - "gOgXdChpI8yZktWGbFmfPgb/Zg1UCZzQjlQ0RCBmaZw79xBtXo/13hKyPMVAY6m4u9zPiUvtpsT1f5yp", - "Oo9lBxPsFWrnJhOLnPmTZHqSslcuiAVo6BHMH5gUy6BHefSqK5LO2VIsls6ZD41R3t1+0rYKLriFnFFv", - "W87mG3GaU5WqT3tRuov9SVSO/ExyrdWa8ofUhaE3zrflzEcfTwzDHgzPa27aFIP9VRiLgI5w1xb5eqHN", - "cYT/DuKPDA0NY4dtanFX3AHstHv3PLybbCRsNTwmgNsCY4tUQR/iTkKzFtAJe0letXFUdVZtKAvf5r/z", - "3uLS5uAl9naYXosOvVtIvGiaWS3o8CMWHtaY3O3VWblH2wZSWWAS0qnZsgJ/yJXEaIvTu8O6eNR0jQyN", - "Jil29P19gSPH7cHjDZ5eH+FBxtanZhe3izfomLa9Afc3xu6h4NzT/+9YdXj+wYG4x3Trlv+ADlaE4zRO", - "MR7YGkSd6D2MNURoOc4aotDCGDZfw0OiEWNJIxgseFU5EW8M4El6DhFNAkgKq3g4P75IR5GbBxDoY5ET", - "RNYj534S20lVDedxAMtXQidtYmvYGnu04v4su0Wn0Hg27NMyoXgjRbxhUO5haJkK/v0HktQh/yyuwfbR", - "vQoo/Li9LdbqFgaV5weZbFG59j00zwDBz1Kn9WFODLOivnCCaVgIY3F79/ogaZD85B8zjLOZVmsD+sQQ", - "R8ZI8U2ZW4qGhdF0tiYLK2R8wYUklwwP/rBDZr4sywQaH4KRB685iI+fjeMKUXosy8X1kMl9xDkCb7r6", - "qYc3gULP2wEG0GVVBbfGbLFVfxGUOg8zPxBde41kjxyta5GW1k8S1gFRH4FXzj/4X3usuRjfD6SU96y7", - "v+aPbbm1WDjOeEuh87wtIh8Vxre+dPHhJRHLDg/0Q7roAq5gu2nsc8L/2N5DkmviRo6oiqktBGqTHd2S", - "h4nulPp5SwdwPoTuicqDHlnxEI+Max2HyAd2S3crqHPrq5uSsbjg7hmm5nHhEma9lGprbsZojcX6eFQp", - "VusPSf8dWCyvekAdiPPvcGXpvHBj8XTwOa8MdA1ViQIuqQIi/p3UpipVpzs/meqgsKo/5SauOqXD2jTW", - "a7UlR5wSEW0lKxXTYxUS3aH4Wq/8zA2iQ9u0sV3G9ieYNwZK5+ZhyI3KV6l6l2Ba+oGG1zRTOq3AtW2L", - "4x5IV7WlXH88VXVsBM05QOe+v3nU2vLt0Q9pbflXjKWFMfl2YtjfL99cvWTUL81uYEOH30TvpTTdX2CD", - "ouKmfc0lX4CeeC9v1q9qCD1v5x/8rz2WZzjJ/gFx0TsxfxQh/rsBD6JHW1Qcp0dTOEWouD9w5KEAGFOi", - "l+aGVFTc5ngStCDp1CWXpZrP2ZOF4lXOSijw5G6T03cX3BVeOsff5JQcaZMMOW7T5tQf2UT51kZL/3B8", - "OpMwrD10n/qbUD+E2766ERUpqlasd8GzorzubjV2ANdr+r7STujfF4T+Ufb98lHZN0CPaIq7JRQmvRu4", - "v4VwrAJMMquQFrRuVp+GXV/J0gdRqfMU0TQ0AK8CkJ8JUVukIUXbgx4/LSW9SJhPQshrEloScuJ1kl9h", - "+iVxQQGE2janp4YEvwZZ+tPvHsiC6p+td5AV9bg8hkhcdgrjM7WqouMX9tgTrev+UC7cDtM0Mks/tgVB", - "Kz/OfOgj77zoesuSjvarrnEq/VEP9qQtXc/btqpG0i8qdpV4EoK77tYLBXbRTxbCioVUGk4Zth/RF0J6", - "nWr3aFQbd/RV6LPEM5fQg6azFVE3ROenqLXEFqH31HhjmVVNsYTS132FHrG8O7dE+F6GqH1MSGOBl/sX", - "QjV72KU31mwXIN/VakcLMhZWdOREk7RPvCiERsIBj40cPmNVWIZPq9PiAh26BeXMNI4Yjo6Y0glFJaF8", - "bwm8nLAflGW8qtQ6oCUsBjvMU1VCjmb7you2gY8wJC+YWfqyKjL5TDgcR8g+7Zjmvj2OE59j5V/c7pEE", - "sPsqzwDGXu3cVi9evPiLPpKT1KZz2Nu/iD4nJhwGFNJqZKgqCaejAFtYpYDtjpX87YH1ZWDChNp8A/oM", - "g3LOKfjcqmIeWzGrM97YpT/NZDQIiqf+mND2HHi34FUV+st5d0JtT4FbzSXNxp5g/UAXuWqHoA5a8hIb", - "D7AGgYq9OvuzOwRFaOawcNp9CMHrCaG3o16XodtrE0PrtLI/gURJCMDHh30yzl4EtEB55ob7sz7TOu+F", - "uvQofJQEaHRa6oHZl47In7+lUPovpoyyYiPxKy/Y2edIj4eLO2megV0DSMaxvY+2uzYjs+htTZOxresl", - "fR7tgPoI30J4+ILHilupt/t3z+MJ8rvnaT/l8/tKrY9X3u1nc8ZSidwWyz+20vaHbBzmDh/z3vH6m1rd", - "9nOg3QkIsgzWcugDd+pXyAWZwP55SnTg076RQUPNRSI04o/6+JTOVXuayXFu1sfxPPc1tUTIhbgphTMD", - "7/KtRoA3nDbEFTcmZJvwc7sG3rEZL9B2xfGTZNG/w9bOvpKH6wHBU5R6U9X8vaibOrv44unTp/gNFv9n", - "YtJHKeenj90dsCv/jEdO0plonmhPgl/29dOnp5//Pt1+mjTJk5e973S2NRGDL3U6j6I9SCMcxypLP7j3", - "ZSr8eJwwox/3jNvmn3sRoM8vOT43m7oS8gb9sjZAJ9tDX1sHbKsjNcd+VATp+u/f5eELJ7MNg/cWJKZv", - "xh3hK/+V1UM6ZO67ff/2H9vCaG4X//d9XX3yDkjHuqED8ih746s/pnWCTtfj2yY+Tt/m3sjHpK+y+IPq", - "zoRM5vfaD9jl/rB0PHdtxM4R0ip0OkL0Stgqjr/556ODu0JKcDx4GHzNLVfV+6ZpZxR1J7mx8/jFnbOX", - "Y2U0VjRjN5gWCyFzNufGns2VXnPtU1QdYPgFIxW+DOBbCXVk9aXMPUxwcn3T4cufCzgoPGm/hh4OjexS", - "hQktise/pc2/x01YUBhhb57z805gnAcReHzRbFP3nkW9aKqA2B2yQeK2FY7Po42dyw0rlJxXorBkO2hY", - "gEQ+kgvmf0JJ+f0Ju44OgvfgxAUFGIoyIeUXUnrhuHMqgJPwMRj7Sv4PW38Mtg4Z451V0eHQ2keJ0PVx", - "f1iIricZYUlbztvn4xPsy5XL/kLGcnpYz4gbsLDRtxf69TDpugnKbsdFEx8/kd7/CMUjlyMeIsBR2dG/", - "iayGLx8cEsyIPpPQj2HgiR3OE4y/XnH4YZw34FjMpj+0cBV9ZyE6VRc3H5E879h/vEGtQyTL+57OxBw7", - "OuG6xcNj6KP2AyIH6qIW8Z9/FKKJDilM8tMvS1+uv6176GMg6Ac4XTNqf5QKjDyxyYxBe0TiQ/aVhneM", - "9Zb6++EEwcm/rTd5d/ffAQAA//8=", + "7H1tc9u2lvBfweh5Zpzs0nLapp1d51OapG3ubdrcOr2d2W5HFyKPJFyTgAKAlnUz/u87OAcgQRGUaNV2", + "0u1+SWS+gAfnDecV+DDJVbVWEqQ1k/MPEw1mraQB/OOV1kq7H7mSFqR1P/l6XYqcW6Hk2T+Nku6ayVdQ", + "cffr/2tYTM4n/++sHfWM7pozGu3m5iabFGByLdZukMk5fWY6cTf8s26o50uQ9q1WORj8e63VGrQVBNoK", + "eGlX7ld3LGPr/JJVwKVhnNlaSyYM07WUQi4ZlwWTihmrgVcMrkBatuKGca3FFRRsoTTDEWZ8YUHPDORK", + "FuYZg2thofDj2hWwNQHmBl8qCdNJNgFZV5PzXyfqcpIRHO4ivjj5LZvY7RoQQC3kcpJNrk/dC6dXXEte", + "uUn9OvkOJ/Wje49+XvhR6K9XfqybbFJyY2cI/4zbPhZ+WYHsgOmeZxutLDDOSiGBWcWENR4Vk2yyULpy", + "Q00KbuHUigom2URptnFDCcuM5dpCMZ2cy7osd6dzk03WonCA+OtCWliCdjcMGCOUnHXut+8Zyy0gZxmm", + "JEOyX9Arzce6s3u3AuYHPXEzcO8nALLcXA599ApkQay9c+smm2h4XwsNhaNIBHs7YPM6TTpMYZcqWWDS", + "lvhq/k/IrQMgnmafu3MN3ELhaZskTW9K4Z35NjnjAURUqoCyz0A/wfsaDLK8e+AZg2pttxH/EwZODCtg", + "wevSTlMgaVUiZYNkiGpdQgXSgu5LRMwKXWDe11A3wtcK9YYL64TaCS1nCw3ATKksq2UBGoHMlcxrrUHm", + "W5bztYmlVBSlQyONPckmXkVMMlSIJYyX2piUF24Gr2no3vW/hW/17vzUfLx360ULzWieDnPMS14XkEQ1", + "PTzbK5s7sjAkA8RCqSFbyUBO6PBoFjN5UkKs5fmq8svOjnxwzxsJNIAsZk7DpZWRqPgywWJ4+ZwV3HL2", + "80/fJ7n5Usgixu5C4JwKsVhMwsDuT77UqFEdqhwKJF+blXL6oNZlkhgV6IoTCbpg+cHO2Rt6ghlV6xyS", + "4K15aj10QJ4zDWt1qqHkVlwBc09OmZ8yl/SLuSeZoGXDgX5i2EbpS6sBMsbnRpW1BaY0a8bBJSQt+M4K", + "6E9msThnS2GZhivheIRpLpfp2QSs7Vk1tN1D532C4shwkNuR2im+/LpU8wRHkoU0o8c/DKvn+1bpKaHt", + "AHc7KXzB1wnra1mqOU8sG2+UsaifI5PLMiVzYDzXyhjGy9KvHSaie0S4NehZq8l4UQg3Ni/fdgDovzca", + "kDVoD0HGLmELBZtv/YUIooCBHXT6iXfATKHtpdCvpNXbPurEUiocbBd3TjDoJq2w4N4/Z9OlsHQZrVd3", + "R9kVaAbXeVkXJLeGVdzmK2en4WJYCA25VXrL+FxdQVdM50qVwKUD061hafES/4IhZYJmo/gXKov51gIS", + "smFoIe1XT5OUNduqFPIy+l4ESZCbXTuW2xYZTJiMLVRZqg0SlPkRmXWPGcu3JiiwoLmmhKzz3YdL4Fce", + "z+FRh7m1EtIaJtVmBRoyj0y4EjlkzKj8EhDBa7HuWPyFcOugXw/wgyNNh8Am77ZreImDxFe+oQHjSz/S", + "4LtsiXT0X2wR7cmYNTw3wKnfC+MMqT6vOqwHtrVQmUMeXsP1DUEnXGu+HV6enCnfMGvWWVo6tNFK2WCE", + "OnvP3XTXkmuH1bXMnVI78D10/SonWX6ijjck24AGpsFpD+fv9OVmB/k4s6xBVvz9FMIbp7qL6wqM8abJ", + "fo0eHkyOfZU0l7ibblLOuTM3Zwc8tGOWrmAsJcyUbal4fK8Ff62V+zm45sP7DgDDqmZ49e/5d+8nHtjM", + "o6kF8eDi6AT0xcrZMAmc+5VrYLmaC8n1Nq0LCyhhz6uqLGZpadLgFEFxTq4/tys2h4XjbxQXvLnXdEw6", + "ZLWJjV5eFOjCVKoQC4E/EVzyoej7/hOzHFGTcKMGJMh/LYtwFyOjQdogKdooVZcWLbL72oDMXiNPLPv5", + "3Ten/8EsXNtn+K9zMlHlpBfPYaz5xXMMo8K1TY6xR4chaCtVFoYpWW6RuGgOM7XAP9yUxustv0Z4HMVf", + "9uANofulc3x6uF47Q2RA8pP4SkNFw6Q+/VrO1fVrC1VC5sZos3mp8ku/3PWJeoyuG/jQrr+IERVHQjfh", + "tdLOIwywAi4K2URzC7NSVOmgYVd99oIkOPo5Q2v/2rJHb7i+LNRGso2wKwqIrJTIwVlJGAPkhv3jlD1i", + "j5Hd/8HO8M9/C3+uRX55qiRaRv84Zb+y3+Lnfr3+LX6Oyy0rhbEMLYXHGQsAZWyz2s4Q61BkTCHAhj0C", + "nq88QCcGR8qc9aZ0Afpxxqq6tGJdAntkdQ246IfvPHaGQq6qCmQBBXvkZlZxfQlFmOAJjvc4Y7wJIpgp", + "I6yfk2w/IkmRvGR+RXXi46NLaCPS6NKxQFluH09Zh1jnDP+bsphk541n0V6lLzXxMnfjlG6Qvnuc+ZH+", + "W06y1JoolBZ2G3OSR+Ykmyx9eAWuBGycDboVabbZv7JqMKq8uiXbNy8NuKedVMIo+xFF+yf/WsqIbAKE", + "B0fB2NmhQIBdaeDF+OBXhMU4EtZTO/HIjXXREDJSQbGtEUJlkQaKcTioCglfRV8b/k78v0A4MDwl5Gt6", + "/bNdkuxgaTS4JmUxtbI6GuIoSJhgl2OUeQGluAJ9N0uAm8MQ83lF2Jlqn0N3ZjRsL3j/eTR5nTeJwTAD", + "eiT7h9n4lxp/MybcQZs5xWMPwAojKdFdU+nGeVg6DciwfD1jcM1zW26ZWx8pA9Esl+3K5xdP9DGjh9xa", + "GR6aesTejv47cFKMAyO3bm0zIG1woVE5TT3cTMkUDOf+Ni+j93mp5DI2HcCtOQEBacf7OCbc5bWGzXC8", + "g0z0zn+1yb+uvZVFkZhsUghTCWOSC2O0WHSGADcAl2bjVEE7Bv429Rq0gWLARHtD5sQQb2O0PcFsX5dq", + "zkRh2EKritVrtyy4a1mfnM5wo2GISkYU0FD/dgw1kP974y77SIswZBRxWbCSW9COrc2U/eIzw4VYLEB7", + "uG0nN4ujZ/FFZjbC5iswsWsaDDBhcK5p3kpy/hu+ZXPwgSFKVXu8aGBLcQUyMdgOvw06OT8oKxa+1uGt", + "hoVJBShFvmISNkw4RiLj102iIMQ5RekkrjYrJqPhpmRNxD6qtwlmQSgTX/tbuMW4cbKIwhlePGfOUJ6y", + "HyWbbxNp2cjJQXszMX7HwKW5pAfYA+MPSp4GkBoFMzBMayQnBort6n2wkFWfGOCdY1pTVxXXAswIhzhB", + "gniq7acaDHankOKht1xbkYs1T8UlCmHWJd/OBsPwS2FnUHFRDt4Nr+5U1LTy4OQLuXDFMchhwDKOWThR", + "gLTCbp+5ZzCMINkSLJOKWc1FCTopiKVaCnnYYqDHsu4cI5jjySURR7b2g9RGeGGZzTWXA8GLAdNhkHIa", + "KmWHbq3VbFwwBFdBj672tR7Azedul9vzKB5aqPbMbfQEerAPwGFVrsqByPitfFK+QR6pbShwWghtLHvK", + "/iq+3lNOFMIhGnIQV00Ic2fa3AzUHBwqcUpm08J3Twxb1GXJSpBLu+pk1foBw7GFS011Rsct9T4lzoOQ", + "5YE7SBaTSnnX3j4/Nkt7QXV4DgmUi+MFXzsDI1d1WThttebaoAldCPxbQ66WUhhgRsgc2HyrIZSmjUnn", + "utnnPkbcp0alDH7CmVjOxsncyg7GEhN1DKt9Jm6Xn28O+MwejQ1kSVLUZvVXSGST1/W8FPnsEhLR7a+5", + "ga+e1rqc9HORtXRwa3AWLXt7+vmXX1Hu04tILyjbfmYIvIt6Hn28J8M1aYt5gGlfaV800olh7lVmINdg", + "U1IJskDI0yHnz7/8qjj2w/SyY6qDJmQDRPPJjKacwpav7voY/sFaq2p9rIeQCipWazsipE/PJXERiqOO", + "i+XnqqqEnZkV7+PrOaO7mBXRAO4ptnFevNMqDkuiWwBwYth3r56/bCpeN9wwyy+TLsRxsSUqDfVpzD7/", + "+aor52MZ66uTGxsuFEQhWF7jeZdfA/h8EFU2GNvMi9XrtkxrRD5qyMzpA/xWGdT5OyVjAUyTEZ9+9swn", + "HxdYhw0Lc+Z09pl73Jx98EHUmzP33tkHeTO9ZT43mwTaDtwUyyV0iiKtUuUs5yWqxVrLGVDy11zOVnXF", + "MQgvqtElHIGD39GX3ilVvqDBd+/UWr7CT+3ceG4uv/Mf3n2F4DhQiJkIP8tJRzYiJMVM2KLnoKl40SSC", + "e5UF4gpmSM30ik/Q+Tr0lF57dQV66/VWKFfHZd2Jp+bokWVMFGUTQzBMSKx9KkYvyZ1mglSk2Je57RsD", + "S+GcDTOfoYE2MsUbFYWPL2AoHVYRJwmMPY9xBYZRjbMtt6HWbaCyjsz9ATqtvdEya4MDo20c42MCNRQz", + "LLpLO+Pdkm2t3DLW1mrztcmCMsEkILmlG1GWpO9GE9t96ydYpOi8E3E4YLi2/J+w3ElL99J5DPOaZID6", + "jONo05Sotx+FoZxRqk3GVFk4IEJs7XZm6h40GQOzvBSB/RK1fLQA3Wr1SzTW9Cf5ndowDDzzZqYYfKz4", + "li0VRrxUbRnv9vD4WKKwjKxphmtknV+mJeFfqpqLlC66CArGp5MDCBhok4qVWD5NgpdhQJTvyCJqrhU3", + "2GYUyEL0HkWU/0LQDjoNEf4jhdRTNpHQZ11d3aVxV+HscuKObHtt2RWmvqJv0ZwmfV/lpBaed9xcJgp6", + "uIGZLzLvF3gMR3HyUpmGawdaphx+0MiqQC+hIOfT5Fz79eZ2EacH6OBxYM5ote9PiyZBhqJ5X3Ozasxi", + "mq4PJTHC2rM4lG5XoAFRIZVdoSgqwkrSIiZArLDlQLADb4UipS4wb9y7rOKXYM5bKtALtbSijJwbA07A", + "JVVBo2QasLPo6+katwO1hf1KNxSXiZ+XzwERG4w0DR3vkuH0PIzVXnoTRm0vvWzHd/IfENk3alWhZsJX", + "QB1A87sfX/44rSiA3mVvr0LQTH/G+By9SLcwE2JRTSQxGfyl28Qwu5UTOLMuwzRiuzt+1gp7VCF4mzCn", + "QzBVaZq0JkmjMYiJJgfL6WqufdLl3Ket9BJO3QgB24hcmkhTrd8VsIwCV1haTh45XAlVt47TicHvJTGP", + "pf6jE+JRdWoqk6whNXEshF90i6+5YWLhVlq9JTWAFdLG8iUWdOmOfzoM/262xWHewxGmNki+gVjJkIzs", + "ZvjwseTgqlCJuvdrYWwy2wckGz2yUhdxI23cYux5p0I9Slo1BBxnqalCYcXjIbPAAx7GH5pxunpyroqt", + "78PtVw66AU+c41WAtJjCZBoqdRV1BPdFyL+0qDWiDeO8ScsQ8hA2TKgzuG4qWyVw7QzeFfACW0CwuyXA", + "l1i8hFu7ZFqa9qxUAXDMHTiws8bydHTFOkc0EX3VRcCOz6iNyDkHpedw3iJgiGAXTlcnegYGQ3NjV2Fa", + "FlTrYB0tSN6Z6MF4IIJ3/03aKWh/ln5RTyQ1qnQ6+gXdCGZTrOmxWpRrkCcDVlXaCzlQ5t8o+y4c32C7", + "F36SCi/augucEwt6tTugD/lXwlosk8XlMIqjUNg/mwhpHDIGoYpGScQEuV0Z0hnpr3V7sxJY6eVGKu9d", + "EDY68HV7A2LAkiT3dW73loEfnSjPJnElyGwdKkv2rQD9UpTfk29PQjCEtJ/XRXLtPRozuy2VYysCvGvc", + "pyHYISh2S/CDX+uVefBWyUB1v5o7Xqeki6mP3OlidFeSrxj2E+vj4gYFdaEiE2gy32pApadpS4nJk+mT", + "6RMqbwTJ12JyPvli+mT6BTVXrBB1Z3wtznhRCXnWOkBLwKXGoRj543UxOZ98C/YiGN+dwuLPnzy5sx1i", + "/BcSW8RcgL4CzWhDDTSta8OdH3qDD+M85qWak4WvTMITvrDYWtv2uyvNaoNdrDxUoE2ZWyHdohySQ5pv", + "6PlnuPI7QrhbBVjIg/eED2IEBiGramPZHNjbH77N2F/evvo2Y9++/sbZyb/A/O2UPbeUaP6SvRFfU9V/", + "F9c/N7m9CfEFGPu1N84iPCNYZ0ux6CK4iTW0HT09ZqNX/7mG5bHvruXRr25gvr7tux0BsbqGmx4bfnZn", + "bIiYTzAh5ogdFxXM8drUCddTYv/UcA18Z00hwNPPvhj99A5jn31w/70ubvaJaMMzewX0T8Y4fSI6TJ4Y", + "Elmy7FG4PYpQyD1xn96CXE6val6BBW0m579+mLjVP/Sz0Vo4IRpOdrk5i+a0O4PfAhtgWDfW0CkVeYph", + "HHp0yl7xfEV/oHfiNBd278P7Z7TXiddy2Dfs/Py/XPz4w5T+NAzjtex7buwpXjl9/ZI98u4+3Xtfg96y", + "ZtqPsexWgzNAnJBQVB6D5gGiX9ChpIUwC/2L3Ib0Mfgva6BK4IR2pKIhAnGSxrlzD9Hm9VjvTGGSpRho", + "KBV3k/kxcartkDj/uxmq9Vj2MMFBoXZuMrHIqd/TqyMpB+WCWIBePYL5A5NiGfQgj75ui6QzthLLlXPm", + "Q2OUd7cfNa2CS24hY9TblrHFVjzOqErVp70o3cW+EaUjP5Nca7Wh/CF1Yeit820589HHE0MbOnhec8Om", + "GOx7YSwCOsBdO+TrhDaHEf47iD/wamgYG7eoxV1xI9hp/+o5vptsIGzV37CF2xxji1RBH+JOQrMG0Cl7", + "SV61cVR1Vm0oC9/lv7PO5NLm4HPs7TCdFh36tpB40dTzStA2dCw8rDG526mzco82DaQyxySkU7NFCX67", + "QYnRFqd3+3XxqOlqGRpNUuzo+/sCRw7bg8cbPJ0+wlHG1sdmF7eK1+iYNr0BtzfGbqHg3NP/eaw6PPvg", + "QDxgurXTv0cHK8JxGqcYD2wMolb07scaIrQcZw1RaGEIm2/gPtGIsaQBDOa8LJ2I1wZwT1OHiDoBJIVV", + "PJx3L9JR5OYeBPpY5ASR9ci5ncS2UlXBWRzA8pXQSZvYGrbBHq24P8vu0Ck0nvX7tEwo3kgRrx+Uux9a", + "poJ//wtJ6pB/Gtdg++heCRR+3F0WK3UFvcrzUSZbVK59C83TQ/DT1L6pmBPDrKgvnGAalsJYXN69Pkga", + "JD/5xwzjbK7VxoA+McSRMVKa7a86ioaFt2mXYxZmyPiSC0kuGW78YfvM/LwoEmi8D0bufWYUHz8dxhWi", + "9FiWi+shk+uIcwTetvVT928ChZ63EQbQ87IMbo3ZYavuJCh1Hka+J7p2GskeOFrXIC2tnyRsAqLugFfO", + "PvhfB6y5GN/3pJQPzLs757u23BosHGe8pdB51hSRDwrjO1+6eP+SiGWHI/2QNrqAM9htGvuU8D+09pDk", + "mriRI6piagqBmmRHO+V+ojulft7RVsj3oXui8qAHVjzEI8NaxyHynt3S/QrqzPrqpmQsLrh7hqlFXLgU", + "ttYMJSJDtMZifdw9D6v1+6T/FiyWV92jDsTx97iydHKDsXhOw4KXBtqGqkQBl1QBEX8ktakK1erOj6Y6", + "KKzqd7mJq05pszaN9VpNyRGnRERTyUrF9FiFRHcovtYpP3Mv0aZt2tg2Y/sTLGoDhXPzMORG5atUvUsw", + "rfyLhlc0UjqtwLVtiuPuSVc1pVx/PlV1bATNOUBnvr950Nry7dH3aW35TwylhTH5dmLY35+/ff2SUb80", + "u4QtbX4TfZfSdH8F2sfYDfuGS74EPfVe3rxb1RB63s4++F8HLM9wpsg94qJzdskgQvwJLveiRxtUHKdH", + "UzhFqLjfcOS+ABhSos/NJamouM3xJGhB0qkrLgu1WLBHS8XLjBWQ4xkKJqMTcNwVXjjH32SUHGmSDBnt", + "Ef/Yb9lE+dZaS/9wvDuTMKw5/oT6m1A/hNu+uhEVKapWrHfBvaK87m40dgDXa/qu0k7o3xeE/kH2/fxB", + "2TdAj2iKuyUUJr1ruL2FcKwCTDKrkBa0rtcfh11fycIHUanzFNHUNwBfByA/EaI2SEOKNhs9flxKepEw", + "H4WQFyS0JOTE6yS/wnRL4oICCLVtTk/1CX4BsvC7392TBdXdW2+UFfWwPIZIXLUK4xO1qqLtFw7YE43r", + "fl8u3B7TNDJL79qCoJkfZz50kXeWt71lSUf7Vds4lT5eiT1qStezpq2qlvSLil0l7oTgrrv5Qo5d9O3R", + "MI8Zth/RWU2dTrVbNKoNO/oq9FninkvoQdPeiqgbov1T1EZii9A1Nd5YZlWdr6DwdV+hRyxr9y0Rvpch", + "ah8T0ljgxeGJUM0edukNNdsFyPe12tGEjIU1bTlRJ+0TLwqhkbDHYwObz1gVpuHT6jS5QId2QhkztSOG", + "oyOmdEJRSSjfWwEvpuwHZRkvS7UJaAmTwQ7zVJWQo9mh8qJd4CMMyXNmVr6sikw+EzbHEbJLO6a5b4/j", + "xOdY+Re3eyQBbM9H68HYqZ3b6cWLJ3/eRXKS2rQPe/MX0efEhM2AQlqNDFUl4fEgwBbWKWDbbSV/u2d9", + "GZgwoTbfgj7FoJxzCj61qpiHVszqlNd25XczGQyC4q4/JrQ9B97NeVmG/nLe7lDbUeBWc0mjsUdYP9BG", + "rppXUAeteIGNB1iDQMVerf3ZboIiNHNYeNwehOD1hNC7Ua/nodtrG0PrtLLfgURJCMDHm30yzl4EtEBx", + "6l73e32mdd4L9dyj8EESoNFuqSOzLy2RP31LofAnpgyyYi3xUB3s7HOkx83FnTTPwW4AJOPY3kfLXZOR", + "WXaWpunQ0vWSDqocUR/hWwjHT3iouJV6u3/3OJ4gv3uc5uSk31dqfbzybo7NGUolcpuv/txKuxB6UER+", + "lPHpbZEp2zmrNDwhwIedfNX4fIvadcpe+dPe8LAqtKKxDLeEhWWqts9YY1cXzclw7oHQLUtRL9whyCli", + "PGhr6FRB922t6uWKTadUfe5P58v8fni1SZf9NkKrD9maFyU3q1MD7iFLLsKIU/QcLO2eH/F5ensk8ONI", + "TXRE4YDcNExxYgLF/uxShFvJjAsqHfPd4Sq2Sl11KwnafURkEXzOsJuCM2KEXJIj6Z+ndCE+7duBNFRc", + "JAKMfsOcjxmiaPYEOi5YcTfxm0OtYRFyIW7t4szA+2ynneYtJ7NyzY0JOduS4y5z79mc5+gB4vvTZOuM", + "w9be7qz766TCvcg6Q1X8WlR1NTn/7MmTJ3iSkf8zMeiDNMXQCZ0jbNufceNW2lnQE+1RiG58+eTJ40/f", + "2sXzcIdY8jnTsKxLrvedOz5l7/wxjOE4Vr+fLacDiBE7zxg1f/qDkOlJJlVo57z3Jdof03uHa/SBVfj3", + "1CzfrS0bTtwcUI50EOWfeR3GxuVhIeicvd9U1/UsWmehNVsyhY29ZeFf7pxxiJwuzOCB/TGvPfPLAB3k", + "53S9Z3yM8DWpHtlsH97KZXdvgwx3NkCQLv7+bRbOyppvGVxbkFgIMCw/ryvKF43ptbx7AfijNsObq+W/", + "X1flR++ld6wbeumPkvUv/pyaAcN3D2+f+4xvU8VB0Uo638tveXoqZLJSpDkKNfPHbuAOngO2vpBWYfgq", + "5EGELeNMjn8+2gIyFJcMp6FC1HIn6OmjnOmwJupOCoh2wgVt2DDDHhvsjcG+Yi2WQmZswY09XSi94doX", + "O7SA4Vl4Kpwx48MLOvJ8Ui5PCBq0+PI7zPZKGAnlot3ruS06SWhR3Eg07QI9bOqbAtIHK2Y+7VT4WRCB", + "hxfNpgjMs6gXTRUQu0c2SNx2ErtZtLBzuXX28KIUuSXbQcMSJPKRXDL/EwoyoqfsIjpSxIMTl6ZhUsOE", + "4pFQHBIOzqBSagl3wdiv5f+x9V2wdag92ttfE7Y/f5BcTxf345I9HckIU9oJYHw6fvGhqivZnchQdQhW", + "xuMCLGx0ik+3sjJdgUd1UnH53d2XZHWPM3rgwvYxAhwVsP5BZDWcoTMmoBcduNON4+HeT84TjM9BGr+t", + "8yU4FrPpI3teRyf2RPuz4+Ijkjvn+2OA1CZEc73v6UzMoU14Lho8PIQ+ao6iGqmLGsR/+pG4OtruNslP", + "v6x849eu7qFjpdAPcLpm0P4oFBh5YpO552az3fvcoSB8Y2iXAn8/7EU7/cN6kzc3/xMAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/git/ignore.go b/internal/git/ignore.go new file mode 100644 index 0000000..ebd26c6 --- /dev/null +++ b/internal/git/ignore.go @@ -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 +} diff --git a/internal/server/files.go b/internal/server/files.go new file mode 100644 index 0000000..ddb3123 --- /dev/null +++ b/internal/server/files.go @@ -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) +} diff --git a/internal/server/files_test.go b/internal/server/files_test.go new file mode 100644 index 0000000..f910f9f --- /dev/null +++ b/internal/server/files_test.go @@ -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) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9028f92..a4fd5c7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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", diff --git a/web/dist/files.js b/web/dist/files.js new file mode 100644 index 0000000..e368d5a --- /dev/null +++ b/web/dist/files.js @@ -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 \ No newline at end of file diff --git a/web/dist/files.js.map b/web/dist/files.js.map new file mode 100644 index 0000000..9d89a68 --- /dev/null +++ b/web/dist/files.js.map @@ -0,0 +1 @@ +{"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAgC,MAAM,UAAU,CAAC;AAC7D,OAAO,EAAE,SAAS,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAQ3C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAiB,CAAC;AAExC,SAAS,IAAI,CAAC,GAAW,EAAE,IAAY;IACrC,OAAO,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACvC,CAAC;AAED,SAAS,MAAM,CAAC,IAAY;IAC1B,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACxE,CAAC;AAED,SAAS,KAAK,CAAC,CAAS;IACtB,IAAI,CAAC,GAAG,IAAI,EAAE,CAAC;QACb,OAAO,GAAG,CAAC,IAAI,CAAC;IAClB,CAAC;IACD,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI,EAAE,CAAC;QACpB,OAAO,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IACxC,CAAC;IACD,OAAO,GAAG,CAAC,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;AAC/C,CAAC;AAED,SAAS,KAAK,CAAC,CAAU;IACvB,OAAO,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAiB;IAC1C,MAAM,IAAI,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAC;IACpD,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC,CAAC;IAC/C,IAAI,OAA4B,CAAC;IACjC,IAAI,OAA4B,CAAC;IACjC,IAAI,KAAK,GAAG,EAAE,CAAC;IACf,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,KAAyB,CAAC;IAC9B,IAAI,QAAQ,GAAG,KAAK,CAAC;IAErB,MAAM,KAAK,GAAG,GAAU,EAAE;QACxB,MAAM,MAAM,GAAG,KAAK,CAAC,cAAc,CAAC;QACpC,OAAO,CAAC,MAAM,IAAI,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC;IACvD,CAAC,CAAC;IAEF,MAAM,EAAE,GAAG,CAAC,CAAQ,EAAE,EAAE;QACtB,MAAM,MAAM,GAAG,KAAK,CAAC,cAAc,CAAC;QACpC,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,OAAO;QACT,CAAC;QACD,MAAM,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACtB,OAAO,GAAG,SAAS,CAAC;QACpB,OAAO,GAAG,SAAS,CAAC;QACpB,KAAK,GAAG,EAAE,CAAC;QACX,MAAM,EAAE,CAAC;QACT,IAAI,CAAC,SAAS,GAAG,CAAC,CAAC;QACnB,KAAK,IAAI,EAAE,CAAC;IACd,CAAC,CAAC;IAEF,MAAM,IAAI,GAAG,KAAK,IAAI,EAAE;QACtB,MAAM,MAAM,GAAG,KAAK,CAAC,cAAc,CAAC;QACpC,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,EAAE,CAAC;YACT,OAAO;QACT,CAAC;QACD,MAAM,OAAO,GAAG,EAAE,MAAM,CAAC;QACzB,MAAM,CAAC,GAAG,KAAK,EAAE,CAAC;QAClB,IAAI,CAAC;YACH,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBACzB,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,0BAA0B,EAAE,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;gBACnG,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;oBACvB,OAAO,GAAG,KAAK,CAAC;oBAChB,OAAO,GAAG,SAAS,CAAC;gBACtB,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,yBAAyB,EAAE,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;gBACjG,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;oBACvB,OAAO,GAAG,KAAK,CAAC;oBAChB,OAAO,GAAG,SAAS,CAAC;gBACtB,CAAC;YACH,CAAC;YACD,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;gBACvB,KAAK,GAAG,EAAE,CAAC;YACb,CAAC;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;gBACvB,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;gBACvB,OAAO,GAAG,SAAS,CAAC;gBACpB,OAAO,GAAG,SAAS,CAAC;YACtB,CAAC;QACH,CAAC;QACD,IAAI,QAAQ,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;YACnC,OAAO;QACT,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,MAAM,EAAE,CAAC;QACT,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC;IAC1B,CAAC,CAAC;IAEF,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,YAAY,CAAC,KAAK,CAAC,CAAC;QACpB,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC;IAC9C,CAAC,CAAC;IAEF,MAAM,MAAM,GAAG,GAAgB,EAAE;QAC/B,MAAM,CAAC,GAAG,KAAK,EAAE,CAAC;QAClB,MAAM,IAAI,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC;QAC7B,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1C,MAAM,IAAI,GAAG,CAAC,KAAa,EAAE,GAAW,EAAE,IAAa,EAAE,EAAE;YACzD,IAAI,IAAI,EAAE,CAAC;gBACT,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,KAAK,CAAC,CAAC;YACtD,CAAC;YACD,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,CAAC,CAAC;YACjE,CAAC,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC;YAC9B,OAAO,CAAC,CAAC;QACX,CAAC,CAAC;QACF,MAAM,KAAK,GAA6B,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,EAAE,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC;QACnF,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;YACxB,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAChB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;QAClF,CAAC,CAAC,CAAC;QACH,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,GAAG,KAAK,CAAC,CAAC;IACjD,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,GAAW,EAAE;QAC3B,IAAI,KAAK,EAAE,CAAC;YACV,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,IAAI,OAAO,EAAE,CAAC;YACZ,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;gBACnB,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC;YAC3C,CAAC;YACD,OAAO,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,wBAAwB,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACzH,CAAC;QACD,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC;QAC1H,CAAC;QACD,OAAO,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;IAChD,CAAC,CAAC;IAEF,MAAM,QAAQ,GAAG,CAAC,GAAW,EAAE,CAAQ,EAAe,EAAE;QACtD,MAAM,KAAK,GAAG,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACvD,MAAM,GAAG,GAAG,CAAC,CACX,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,EACrC,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,mEAAmE,CAAC,CAAC,CAAC,EAAE,EAAE,EAChK,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EACnG,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAC1J,CAAC;QACF,IAAI,CAAC,CAAC,IAAI,KAAK,KAAK,EAAE,CAAC;YACrB,GAAG,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACrD,CAAC;aAAM,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YAC7B,GAAG,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC3D,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC,CAAC;IAEF,MAAM,MAAM,GAAG,GAAG,EAAE;QAClB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;QAC1E,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;YAC1B,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,mBAAmB,CAAC,CAAC,CAAC;YACjE,OAAO;QACT,CAAC;QACD,IAAI,KAAK,EAAE,CAAC;YACV,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,kBAAkB,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC;YAC9D,OAAO;QACT,CAAC;QACD,IAAI,OAAO,EAAE,CAAC;YACZ,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;gBACnB,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,cAAc,CAAC,CAAC,CAAC;gBAC5D,OAAO;YACT,CAAC;YACD,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACvC,IAAI,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC;gBACxB,KAAK,CAAC,GAAG,EAAE,CAAC;YACd,CAAC;YACD,OAAO,CACL,IAAI,EACJ,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAC7F,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,qBAAqB,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CACzI,CAAC;YACF,OAAO;QACT,CAAC;QACD,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,UAAU,CAAC,CAAC,CAAC;YACxD,OAAO;QACT,CAAC;QACD,MAAM,CAAC,GAAG,OAAO,CAAC;QAClB,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC1H,IAAI,EAAE,EAAE,CAAC;YACP,EAAE,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACjD,CAAC;QACD,OAAO,CACL,IAAI,EACJ,CAAC,CACC,KAAK,EACL,EAAE,KAAK,EAAE,SAAS,EAAE,EACpB,EAAE,EACF,GAAG,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAC5C,CAAC,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,kBAAkB,CAAC,CAAC,CAAC,CAAC,IAAI,EAChF,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,kBAAkB,CAAC,CAAC,OAAO,CAAC,MAAM,qBAAqB,CAAC,CAAC,CAAC,CAAC,IAAI,CAC1G,CACF,CAAC;IACJ,CAAC,CAAC;IAEF,MAAM,IAAI,GAAG;QACX,MAAM,CAAC,CAAC,WAAW,CAAC,EAAE,GAAG,EAAE;YACzB,MAAM,EAAE,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,GAAG,SAAS,CAAC;YACpB,OAAO,GAAG,SAAS,CAAC;YACpB,KAAK,GAAG,EAAE,CAAC;YACX,MAAM,EAAE,CAAC;YACT,KAAK,IAAI,EAAE,CAAC;QACd,CAAC,CAAC;QACF,MAAM,CAAC,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,EAAE;YAC/B,IAAK,KAAqB,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,IAAI,KAAK,mBAAmB,IAAI,EAAE,CAAC,IAAI,KAAK,YAAY,CAAC,EAAE,CAAC;gBACrG,IAAI,EAAE,CAAC;YACT,CAAC;QACH,CAAC,CAAC;KACH,CAAC;IAEF,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAC7D,MAAM,EAAE,CAAC;IACT,KAAK,IAAI,EAAE,CAAC;IACZ,OAAO,GAAG,EAAE;QACV,QAAQ,GAAG,IAAI,CAAC;QAChB,YAAY,CAAC,KAAK,CAAC,CAAC;QACpB,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,GAAG,EAAE,CAAC;QACR,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/web/dist/panes.js b/web/dist/panes.js index 6ff480b..32a96b5 100644 --- a/web/dist/panes.js +++ b/web/dist/panes.js @@ -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")) { diff --git a/web/dist/panes.js.map b/web/dist/panes.js.map index d4bf874..59ea545 100644 --- a/web/dist/panes.js.map +++ b/web/dist/panes.js.map @@ -1 +1 @@ -{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"panes.js","sourceRoot":"","sources":["../src/panes.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/web/src/api.gen.ts b/web/src/api.gen.ts index 27cd486..e5623a4 100644 --- a/web/src/api.gen.ts +++ b/web/src/api.gen.ts @@ -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; diff --git a/web/src/files.ts b/web/src/files.ts new file mode 100644 index 0000000..cc5adcd --- /dev/null +++ b/web/src/files.ts @@ -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(); + +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(); + } + }; +} diff --git a/web/src/panes.ts b/web/src/panes.ts index 7ae87e6..db191de 100644 --- a/web/src/panes.ts +++ b/web/src/panes.ts @@ -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 () => 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 () => 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[] = []; diff --git a/web/style.css b/web/style.css index 4c192cc..9f4610c 100644 --- a/web/style.css +++ b/web/style.css @@ -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;