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:
co-authored by
Claude Fable 5.1
parent
e5d3fb2afd
commit
fb3b8e4b4c
@@ -0,0 +1,198 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// IsCrossDevice reports a rename refused because the two ends are on different
|
||||
// filesystems — on a NAS that is every move between datasets. The Sudo backend only has
|
||||
// the helper's words for it.
|
||||
func IsCrossDevice(err error) bool {
|
||||
return errors.Is(err, syscall.EXDEV) || (err != nil && strings.Contains(err.Error(), "cross-device"))
|
||||
}
|
||||
|
||||
// Inside reports whether to lies within from — a folder cannot be moved into itself.
|
||||
func Inside(from, to string) bool {
|
||||
return to == from || strings.HasPrefix(to, strings.TrimSuffix(from, "/")+"/")
|
||||
}
|
||||
|
||||
// Move is Rename, and across filesystems a copy and a removal. The copy is built under a
|
||||
// hidden temporary name beside where it will land and renamed into place only once it is
|
||||
// complete, and the source is removed only after that: a failure part-way, or a cancel,
|
||||
// leaves the source exactly as it was. Progress goes to w, a line at a time, for the task
|
||||
// output that a long move is watched through.
|
||||
func (Local) Move(ctx context.Context, from, to string, w io.Writer) error {
|
||||
if protected[from] {
|
||||
return errors.New("refusing to move " + from)
|
||||
}
|
||||
if Inside(from, to) {
|
||||
return errors.New("a folder can't be moved into itself")
|
||||
}
|
||||
if _, err := os.Lstat(to); err == nil {
|
||||
return errors.New("destination exists")
|
||||
}
|
||||
err := os.Rename(from, to)
|
||||
if err == nil || !IsCrossDevice(err) {
|
||||
return err
|
||||
}
|
||||
return moveByCopy(ctx, from, to, w)
|
||||
}
|
||||
|
||||
// moveByCopy is the slow path: copy, rename into place, remove the original.
|
||||
func moveByCopy(ctx context.Context, from, to string, w io.Writer) error {
|
||||
st, err := os.Lstat(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(to), "."+filepath.Base(to)+".hamlab-moving")
|
||||
_ = os.RemoveAll(tmp) // a previous attempt that died
|
||||
fmt.Fprintf(w, "%s is on another filesystem: copying, then removing the original\n", filepath.Dir(to))
|
||||
p := &progress{w: w}
|
||||
if err := copyPath(ctx, from, tmp, st, p); err != nil {
|
||||
fmt.Fprintln(w, "copy failed; removing the partial copy, the original is untouched")
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, to); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(w, "copied %s; removing the original\n", p.String())
|
||||
if err := os.RemoveAll(from); err != nil {
|
||||
return fmt.Errorf("copied to %s, but the original could not be removed: %w", to, err)
|
||||
}
|
||||
fmt.Fprintf(w, "moved %s to %s\n", from, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
type progress struct {
|
||||
w io.Writer
|
||||
files int64
|
||||
bytes int64
|
||||
shownFiles int64
|
||||
shownBytes int64
|
||||
lastPrinted bool
|
||||
}
|
||||
|
||||
func (p *progress) add(files, bytes int64) {
|
||||
p.files += files
|
||||
p.bytes += bytes
|
||||
if p.files-p.shownFiles >= 500 || p.bytes-p.shownBytes >= 512<<20 {
|
||||
fmt.Fprintf(p.w, "copying… %s\n", p.String())
|
||||
p.shownFiles, p.shownBytes = p.files, p.bytes
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progress) String() string {
|
||||
return fmt.Sprintf("%d files, %s", p.files, fmtSize(p.bytes))
|
||||
}
|
||||
|
||||
func fmtSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit && exp < 4; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
|
||||
// copyPath copies one entry of any kind, recursively for a directory, keeping mode, times
|
||||
// and — when running as root — ownership. Devices, pipes and sockets are refused: a copy
|
||||
// of one is not the thing itself.
|
||||
func copyPath(ctx context.Context, src, dst string, st os.FileInfo, p *progress) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := st.Mode()
|
||||
switch {
|
||||
case mode&os.ModeSymlink != 0:
|
||||
target, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
chown(dst, st)
|
||||
p.add(1, 0)
|
||||
return nil
|
||||
case mode.IsDir():
|
||||
if err := os.Mkdir(dst, mode.Perm()|0o700); err != nil { // writable while it fills, exact mode after
|
||||
return err
|
||||
}
|
||||
ents, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range ents {
|
||||
lst, err := e.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyPath(ctx, filepath.Join(src, e.Name()), filepath.Join(dst, e.Name()), lst, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
chown(dst, st)
|
||||
if err := os.Chmod(dst, mode.Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(dst, st.ModTime(), st.ModTime())
|
||||
case mode.IsRegular():
|
||||
n, err := copyFile(src, dst, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.add(1, n)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s: can't copy a device, pipe or socket", src)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, st os.FileInfo) (int64, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode().Perm())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := io.Copy(out, in)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return n, err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return n, err
|
||||
}
|
||||
chown(dst, st)
|
||||
if err := os.Chmod(dst, st.Mode().Perm()); err != nil { // umask has no say
|
||||
return n, err
|
||||
}
|
||||
return n, os.Chtimes(dst, st.ModTime(), st.ModTime())
|
||||
}
|
||||
|
||||
// chown keeps the owner when this process may set one; an ordinary user's copy is the
|
||||
// user's, which is what a rename would have left anyway.
|
||||
func chown(dst string, st os.FileInfo) {
|
||||
if os.Geteuid() != 0 {
|
||||
return
|
||||
}
|
||||
if sys, ok := st.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Lchown(dst, int(sys.Uid), int(sys.Gid))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user