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
+37 -2
View File
@@ -15,6 +15,7 @@ import (
"io"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"sort"
@@ -62,6 +63,7 @@ type FS interface {
Mkdir(ctx context.Context, path string) error
Touch(ctx context.Context, path string) error
Rename(ctx context.Context, from, to string) error
Move(ctx context.Context, from, to string, progress io.Writer) error // Rename, or across filesystems a copy and a removal; long, so callers run it as a task
Delete(ctx context.Context, path string, recursive bool) error
Chmod(ctx context.Context, path string, perm os.FileMode) error
}
@@ -385,6 +387,30 @@ func (s Sudo) Touch(ctx context.Context, path string) error {
func (s Sudo) Rename(ctx context.Context, from, to string) error {
return s.runJSON(ctx, nil, "rename", from, to)
}
// Move as root: the helper does the copying and talks on stdout.
func (s Sudo) Move(ctx context.Context, from, to string, progress io.Writer) error {
c := s.cmd(ctx, "move", from, to)
c.Stdout = progress
var errb strings.Builder
c.Stderr = &errb
if err := c.Run(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
// MoveCmd is the command a task runs for a long move: this binary's helper, as root when
// the backend is.
func MoveCmd(ctx context.Context, fsys FS, self, from, to string) *exec.Cmd {
if s, ok := fsys.(Sudo); ok {
return s.cmd(ctx, "move", from, to)
}
return exec.CommandContext(ctx, self, "fs", "move", from, to)
}
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
if recursive {
return s.runJSON(ctx, nil, "delete", "-r", path)
@@ -399,7 +425,7 @@ func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error {
func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
if len(args) < 2 {
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|delete|chmod> …"))
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|move|delete|chmod> …"))
}
ctx := context.Background()
var l Local
@@ -452,7 +478,7 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
return fail(err)
}
}
case "rename":
case "rename", "move":
from, err := clean(1)
if err != nil {
return fail(err)
@@ -461,6 +487,15 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
if err != nil {
return fail(err)
}
if op == "move" {
// A cancelled task is a SIGTERM: stop copying and take the partial copy away.
mctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
defer stop()
if err := l.Move(mctx, from, to, stdout); err != nil {
return fail(err)
}
return 0
}
if err := l.Rename(ctx, from, to); err != nil {
return fail(err)
}