@@ -12,11 +12,15 @@ import (
1212 "strconv"
1313 "syscall"
1414
15+ "github.com/icholy/replace"
16+ "github.com/spf13/afero"
17+ "golang.org/x/text/transform"
1518 "golang.org/x/xerrors"
1619
1720 "cdr.dev/slog"
1821 "github.com/coder/coder/v2/coderd/httpapi"
1922 "github.com/coder/coder/v2/codersdk"
23+ "github.com/coder/coder/v2/codersdk/workspacesdk"
2024)
2125
2226type HTTPResponseCode = int
@@ -165,3 +169,105 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
165169
166170 return 0 , nil
167171}
172+
173+ func (a * agent ) HandleEditFiles (rw http.ResponseWriter , r * http.Request ) {
174+ ctx := r .Context ()
175+
176+ var req workspacesdk.FileEditRequest
177+ if ! httpapi .Read (ctx , rw , r , & req ) {
178+ return
179+ }
180+
181+ if len (req .Files ) == 0 {
182+ httpapi .Write (ctx , rw , http .StatusBadRequest , codersdk.Response {
183+ Message : "must specify at least one file" ,
184+ })
185+ return
186+ }
187+
188+ var combinedErr error
189+ status := http .StatusOK
190+ for _ , edit := range req .Files {
191+ s , err := a .editFile (r .Context (), edit .Path , edit .Edits )
192+ // Keep the highest response status, so 500 will be preferred over 400, etc.
193+ if s > status {
194+ status = s
195+ }
196+ if err != nil {
197+ combinedErr = errors .Join (combinedErr , err )
198+ }
199+ }
200+
201+ if combinedErr != nil {
202+ httpapi .Write (ctx , rw , status , codersdk.Response {
203+ Message : combinedErr .Error (),
204+ })
205+ return
206+ }
207+
208+ httpapi .Write (ctx , rw , http .StatusOK , codersdk.Response {
209+ Message : "Successfully edited file(s)" ,
210+ })
211+ }
212+
213+ func (a * agent ) editFile (ctx context.Context , path string , edits []workspacesdk.FileEdit ) (int , error ) {
214+ if path == "" {
215+ return http .StatusBadRequest , xerrors .New ("\" path\" is required" )
216+ }
217+
218+ if ! filepath .IsAbs (path ) {
219+ return http .StatusBadRequest , xerrors .Errorf ("file path must be absolute: %q" , path )
220+ }
221+
222+ if len (edits ) == 0 {
223+ return http .StatusBadRequest , xerrors .New ("must specify at least one edit" )
224+ }
225+
226+ f , err := a .filesystem .Open (path )
227+ if err != nil {
228+ status := http .StatusInternalServerError
229+ switch {
230+ case errors .Is (err , os .ErrNotExist ):
231+ status = http .StatusNotFound
232+ case errors .Is (err , os .ErrPermission ):
233+ status = http .StatusForbidden
234+ }
235+ return status , err
236+ }
237+ defer f .Close ()
238+
239+ stat , err := f .Stat ()
240+ if err != nil {
241+ return http .StatusInternalServerError , err
242+ }
243+
244+ if stat .IsDir () {
245+ return http .StatusBadRequest , xerrors .Errorf ("open %s: not a file" , path )
246+ }
247+
248+ transforms := make ([]transform.Transformer , len (edits ))
249+ for i , edit := range edits {
250+ transforms [i ] = replace .String (edit .Search , edit .Replace )
251+ }
252+
253+ tmpfile , err := afero .TempFile (a .filesystem , "" , filepath .Base (path ))
254+ if err != nil {
255+ return http .StatusInternalServerError , err
256+ }
257+ defer tmpfile .Close ()
258+
259+ _ , err = io .Copy (tmpfile , replace .Chain (f , transforms ... ))
260+ if err != nil {
261+ if rerr := a .filesystem .Remove (tmpfile .Name ()); rerr != nil {
262+ a .logger .Warn (ctx , "unable to clean up temp file" , slog .Error (rerr ))
263+ }
264+ return http .StatusInternalServerError , xerrors .Errorf ("edit %s: %w" , path , err )
265+ }
266+
267+ err = a .filesystem .Rename (tmpfile .Name (), path )
268+ if err != nil {
269+ return http .StatusInternalServerError , err
270+ }
271+
272+ return 0 , nil
273+ }
0 commit comments