Files: Move… opens a folder picker, and a move across filesystems copies then removes

The picker walks the machine like the tab does — back, forward, up, breadcrumbs, a path
box, a new folder — and "Move here" puts the file or folder in the folder being looked
at; it says why it can't when it can't (already here, into itself, a name clash). POST
/api/files/move is a rename when the two ends share a filesystem and otherwise a task:
on a NAS every move between datasets is a different filesystem, and a rename there is
EXDEV. The copy is built under a hidden temporary name beside its destination, renamed
into place once complete, and only then is the original removed, so a failure or a
cancel leaves the original as it was. Modes, times, symlinks and, as root, owners are kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 16:27:55 -07:00
co-authored by Claude Fable 5.1
parent e5d3fb2afd
commit fb3b8e4b4c
7 changed files with 567 additions and 6 deletions
+54
View File
@@ -80,6 +80,7 @@ type Server struct {
fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo
thumbs *thumbSvc
self string // this binary, for helpers run as tasks
nodes *nodes.Manager
fingerprint string
version string
@@ -99,6 +100,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
if err != nil {
self = "hamlab"
}
s.self = self
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
s.thumbs = newThumbSvc(cfg.DataDir(), self)
s.hostName() // read it once now so the first request doesn't have to
@@ -301,6 +303,7 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("POST /api/files/mkdir", s.auth(s.filesMkdir))
m.HandleFunc("POST /api/files/touch", s.auth(s.filesTouch))
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
m.HandleFunc("POST /api/files/move", s.auth(s.filesMove))
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb))
@@ -2054,6 +2057,57 @@ func (s *Server) filesRename(w http.ResponseWriter, r *http.Request) {
})
}
// filesMove: POST {path, to} — to is the full destination path. A rename when the two
// ends share a filesystem (204); otherwise the copy-and-remove runs as a task (202 with the
// task), since a folder between datasets can take as long as it takes.
func (s *Server) filesMove(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
var body struct{ Path, To string }
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
from, err := files.Clean(body.Path)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
to, err := files.Clean(body.To)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if from == to {
jsonErr(w, 400, "it is already there")
return
}
if files.Inside(from, to) {
jsonErr(w, 400, "a folder can't be moved into itself")
return
}
err = fsys.Rename(r.Context(), from, to)
if err == nil {
log.Printf("files: move %s -> %s (from %s)", from, to, clientIP(r))
w.WriteHeader(204)
return
}
if !files.IsCrossDevice(err) {
fsErr(w, err)
return
}
cmd := files.MoveCmd(context.Background(), fsys, s.self, from, to)
t, err := s.tasks.Start("files:move", "move "+filepath.Base(from)+" to "+filepath.Dir(to), cmd)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("files: move %s -> %s across filesystems as task %s (from %s)", from, to, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) filesDelete(w http.ResponseWriter, r *http.Request) {
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
rec, _ := body["recursive"].(bool)