forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | // Package files exposes the workspace file API consumed by the SPA's file |
| 2 | // tree, preview and editor panels: list a directory, search files by name, | |
| 3 | // read a text file, stream a file's raw bytes, write a file back. | |
| 4 | // | |
| 5 | // Access is deliberately unrestricted (absolute paths welcome): ori is meant | |
| 6 | // to run inside an already-isolated sandbox, and the agent itself has the | |
| 7 | // same reach. Relative paths resolve against the configured root (the --cwd | |
| 8 | // given to the agent session). | |
| 9 | package files | |
| 10 | ||
| 11 | import ( | |
| 12 | "encoding/json" | |
| 13 | "errors" | |
| 14 | "io/fs" | |
| 15 | "net/http" | |
| 16 | "os" | |
| 17 | "path/filepath" | |
| 18 | "sort" | |
| 19 | "unicode/utf8" | |
| 20 | ) | |
| 21 | ||
| 22 | // maxFileBytes bounds what the read endpoint returns; larger files are | |
| 23 | // refused rather than truncated, so the editor never silently saves a | |
| 24 | // partial file back. | |
| 25 | const maxFileBytes = 5 << 20 // 5 MiB | |
| 26 | ||
| 27 | // Service answers the file API requests for one workspace root. | |
| 28 | type Service struct { | |
| 29 | root string | |
| 30 | } | |
| 31 | ||
| 32 | // New creates a Service whose relative paths resolve against root. | |
| 33 | // | |
| 34 | // Example: | |
| 35 | // | |
| 36 | // svc := files.New("/work/my-project") | |
| 37 | // for pattern, handler := range svc.Routes() { | |
| 38 | // mux.Handle(pattern, handler) | |
| 39 | // } | |
| 40 | func New(root string) *Service { | |
| 41 | return &Service{root: root} | |
| 42 | } | |
| 43 | ||
| 44 | // Routes returns the API endpoints, keyed by http.ServeMux pattern, ready to | |
| 45 | // be merged into the server's route table. | |
| 46 | func (s *Service) Routes() map[string]http.Handler { | |
| 47 | return map[string]http.Handler{ | |
| 48 | "GET /api/files": http.HandlerFunc(s.listDirectory), | |
| 49 | "GET /api/files/search": http.HandlerFunc(s.searchFiles), | |
| 50 | "GET /api/file": http.HandlerFunc(s.readFile), | |
| 51 | "PUT /api/file": http.HandlerFunc(s.writeFile), | |
| 52 | "GET /api/raw": http.HandlerFunc(s.rawFile), | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | // Entry describes one child of a listed directory. | |
| 57 | type Entry struct { | |
| 58 | // Name is the base name of the entry. | |
| 59 | Name string `json:"name"` | |
| 60 | // Path is the resolved path, directly usable in further API calls. | |
| 61 | Path string `json:"path"` | |
| 62 | // IsDir tells directories and files apart. | |
| 63 | IsDir bool `json:"isDir"` | |
| 64 | // Size is the file size in bytes (0 for directories). | |
| 65 | Size int64 `json:"size"` | |
| 66 | } | |
| 67 | ||
| 68 | // resolve turns a request path into the path to operate on: empty means the | |
| 69 | // root, relative paths are joined to it, absolute paths are used as-is. | |
| 70 | func (s *Service) resolve(path string) string { | |
| 71 | if path == "" { | |
| 72 | return s.root | |
| 73 | } | |
| 74 | if !filepath.IsAbs(path) { | |
| 75 | return filepath.Join(s.root, path) | |
| 76 | } | |
| 77 | return filepath.Clean(path) | |
| 78 | } | |
| 79 | ||
| 80 | func (s *Service) listDirectory(w http.ResponseWriter, r *http.Request) { | |
| 81 | dir := s.resolve(r.URL.Query().Get("path")) | |
| 82 | dirEntries, err := os.ReadDir(dir) | |
| 83 | if err != nil { | |
| 84 | writeFSError(w, err) | |
| 85 | return | |
| 86 | } | |
| 87 | ||
| 88 | entries := make([]Entry, 0, len(dirEntries)) | |
| 89 | for _, e := range dirEntries { | |
| 90 | info, infoErr := e.Info() | |
| 91 | size := int64(0) | |
| 92 | if infoErr == nil && !e.IsDir() { | |
| 93 | size = info.Size() | |
| 94 | } | |
| 95 | entries = append(entries, Entry{ | |
| 96 | Name: e.Name(), | |
| 97 | Path: filepath.Join(dir, e.Name()), | |
| 98 | IsDir: e.IsDir(), | |
| 99 | Size: size, | |
| 100 | }) | |
| 101 | } | |
| 102 | // Directories first, then files, each group alphabetically — the order | |
| 103 | // the file tree renders in. | |
| 104 | sort.Slice(entries, func(i, j int) bool { | |
| 105 | if entries[i].IsDir != entries[j].IsDir { | |
| 106 | return entries[i].IsDir | |
| 107 | } | |
| 108 | return entries[i].Name < entries[j].Name | |
| 109 | }) | |
| 110 | ||
| 111 | writeJSON(w, http.StatusOK, map[string]any{"path": dir, "entries": entries}) | |
| 112 | } | |
| 113 | ||
| 114 | func (s *Service) readFile(w http.ResponseWriter, r *http.Request) { | |
| 115 | path := s.resolve(r.URL.Query().Get("path")) | |
| 116 | info, err := os.Stat(path) | |
| 117 | if err != nil { | |
| 118 | writeFSError(w, err) | |
| 119 | return | |
| 120 | } | |
| 121 | if info.IsDir() { | |
| 122 | writeError(w, http.StatusBadRequest, "path is a directory") | |
| 123 | return | |
| 124 | } | |
| 125 | if info.Size() > maxFileBytes { | |
| 126 | writeError(w, http.StatusRequestEntityTooLarge, "file larger than 5 MiB") | |
| 127 | return | |
| 128 | } | |
| 129 | content, err := os.ReadFile(path) | |
| 130 | if err != nil { | |
| 131 | writeFSError(w, err) | |
| 132 | return | |
| 133 | } | |
| 134 | if !utf8.Valid(content) { | |
| 135 | writeError(w, http.StatusUnsupportedMediaType, "not a text file") | |
| 136 | return | |
| 137 | } | |
| 138 | writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": string(content)}) | |
| 139 | } | |
| 140 | ||
| 141 | func (s *Service) writeFile(w http.ResponseWriter, r *http.Request) { | |
| 142 | path := s.resolve(r.URL.Query().Get("path")) | |
| 143 | if r.URL.Query().Get("path") == "" { | |
| 144 | writeError(w, http.StatusBadRequest, "path query parameter is required") | |
| 145 | return | |
| 146 | } | |
| 147 | ||
| 148 | var body struct { | |
| 149 | Content string `json:"content"` | |
| 150 | } | |
| 151 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFileBytes)).Decode(&body); err != nil { | |
| 152 | writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) | |
| 153 | return | |
| 154 | } | |
| 155 | if dir := filepath.Dir(path); dir != "" { | |
| 156 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 157 | writeFSError(w, err) | |
| 158 | return | |
| 159 | } | |
| 160 | } | |
| 161 | if err := os.WriteFile(path, []byte(body.Content), 0o644); err != nil { | |
| 162 | writeFSError(w, err) | |
| 163 | return | |
| 164 | } | |
| 165 | writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "saved"}) | |
| 166 | } | |
| 167 | ||
| 168 | // writeFSError maps filesystem errors to HTTP statuses. | |
| 169 | func writeFSError(w http.ResponseWriter, err error) { | |
| 170 | status := http.StatusInternalServerError | |
| 171 | switch { | |
| 172 | case errors.Is(err, fs.ErrNotExist): | |
| 173 | status = http.StatusNotFound | |
| 174 | case errors.Is(err, fs.ErrPermission): | |
| 175 | status = http.StatusForbidden | |
| 176 | } | |
| 177 | writeError(w, status, err.Error()) | |
| 178 | } | |
| 179 | ||
| 180 | func writeError(w http.ResponseWriter, status int, message string) { | |
| 181 | writeJSON(w, status, map[string]any{"error": message}) | |
| 182 | } | |
| 183 | ||
| 184 | func writeJSON(w http.ResponseWriter, status int, payload any) { | |
| 185 | w.Header().Set("Content-Type", "application/json") | |
| 186 | w.WriteHeader(status) | |
| 187 | _ = json.NewEncoder(w).Encode(payload) | |
| 188 | } |