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
@@ -80,6 +80,7 @@ type Server struct {
|
|||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
thumbs *thumbSvc
|
thumbs *thumbSvc
|
||||||
|
self string // this binary, for helpers run as tasks
|
||||||
nodes *nodes.Manager
|
nodes *nodes.Manager
|
||||||
fingerprint string
|
fingerprint string
|
||||||
version string
|
version string
|
||||||
@@ -99,6 +100,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
self = "hamlab"
|
self = "hamlab"
|
||||||
}
|
}
|
||||||
|
s.self = self
|
||||||
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
|
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
|
||||||
s.thumbs = newThumbSvc(cfg.DataDir(), self)
|
s.thumbs = newThumbSvc(cfg.DataDir(), self)
|
||||||
s.hostName() // read it once now so the first request doesn't have to
|
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/mkdir", s.auth(s.filesMkdir))
|
||||||
m.HandleFunc("POST /api/files/touch", s.auth(s.filesTouch))
|
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/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/delete", s.auth(s.filesDelete))
|
||||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||||
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb))
|
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) {
|
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 {
|
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
|
||||||
rec, _ := body["recursive"].(bool)
|
rec, _ := body["recursive"].(bool)
|
||||||
|
|||||||
+37
-2
@@ -15,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -62,6 +63,7 @@ type FS interface {
|
|||||||
Mkdir(ctx context.Context, path string) error
|
Mkdir(ctx context.Context, path string) error
|
||||||
Touch(ctx context.Context, path string) error
|
Touch(ctx context.Context, path string) error
|
||||||
Rename(ctx context.Context, from, to 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
|
Delete(ctx context.Context, path string, recursive bool) error
|
||||||
Chmod(ctx context.Context, path string, perm os.FileMode) 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 {
|
func (s Sudo) Rename(ctx context.Context, from, to string) error {
|
||||||
return s.runJSON(ctx, nil, "rename", from, to)
|
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 {
|
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
|
||||||
if recursive {
|
if recursive {
|
||||||
return s.runJSON(ctx, nil, "delete", "-r", path)
|
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 {
|
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 }
|
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
|
||||||
if len(args) < 2 {
|
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()
|
ctx := context.Background()
|
||||||
var l Local
|
var l Local
|
||||||
@@ -452,7 +478,7 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
|||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "rename":
|
case "rename", "move":
|
||||||
from, err := clean(1)
|
from, err := clean(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(err)
|
return fail(err)
|
||||||
@@ -461,6 +487,15 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(err)
|
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 {
|
if err := l.Rename(ctx, from, to); err != nil {
|
||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInside(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
from, to string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"/a/b", "/a/b", true}, {"/a/b", "/a/b/c", true}, {"/a/b", "/a/bc", false}, {"/a/b", "/a", false}, {"/", "/x", true},
|
||||||
|
} {
|
||||||
|
if got := Inside(c.from, c.to); got != c.want {
|
||||||
|
t.Errorf("Inside(%q, %q) = %v, want %v", c.from, c.to, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCrossDevice(t *testing.T) {
|
||||||
|
if !IsCrossDevice(&os.LinkError{Op: "rename", Old: "/a", New: "/b", Err: syscall.EXDEV}) {
|
||||||
|
t.Fatal("the kernel's EXDEV")
|
||||||
|
}
|
||||||
|
if !IsCrossDevice(errors.New("rename /a /b: invalid cross-device link")) {
|
||||||
|
t.Fatal("the helper's words for it")
|
||||||
|
}
|
||||||
|
if IsCrossDevice(errors.New("permission denied")) || IsCrossDevice(nil) {
|
||||||
|
t.Fatal("anything else is not")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The copy path, exercised directly: a tree with nested folders, a symlink, an odd mode
|
||||||
|
// and old mtimes lands whole under the new name, and the original is gone only then.
|
||||||
|
func TestMoveByCopy(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "src", "album")
|
||||||
|
if err := os.MkdirAll(filepath.Join(src, "sub", "deep"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
write := func(p, s string, mode os.FileMode) {
|
||||||
|
if err := os.WriteFile(p, []byte(s), mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = os.Chmod(p, mode)
|
||||||
|
_ = os.Chtimes(p, old, old)
|
||||||
|
}
|
||||||
|
write(filepath.Join(src, "a.txt"), "hello", 0o644)
|
||||||
|
write(filepath.Join(src, "sub", "deep", "b.bin"), strings.Repeat("x", 100000), 0o600)
|
||||||
|
write(filepath.Join(src, "run.sh"), "#!/bin/sh\n", 0o755)
|
||||||
|
if err := os.Symlink("a.txt", filepath.Join(src, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = os.Chtimes(filepath.Join(src, "sub"), old, old)
|
||||||
|
|
||||||
|
dst := filepath.Join(root, "dst", "moved")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
if err := moveByCopy(context.Background(), src, dst, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(src); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("the original is still there")
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(dst, "a.txt")); string(b) != "hello" {
|
||||||
|
t.Fatal("a.txt")
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(filepath.Join(dst, "sub", "deep", "b.bin")); err != nil || st.Size() != 100000 || st.Mode().Perm() != 0o600 || !st.ModTime().Equal(old) {
|
||||||
|
t.Fatalf("b.bin: %v %v", st, err)
|
||||||
|
}
|
||||||
|
if st, _ := os.Stat(filepath.Join(dst, "run.sh")); st.Mode().Perm() != 0o755 {
|
||||||
|
t.Fatal("mode lost")
|
||||||
|
}
|
||||||
|
if target, err := os.Readlink(filepath.Join(dst, "link")); err != nil || target != "a.txt" {
|
||||||
|
t.Fatal("symlink not copied as a symlink")
|
||||||
|
}
|
||||||
|
if st, _ := os.Stat(filepath.Join(dst, "sub")); !st.ModTime().Equal(old) {
|
||||||
|
t.Fatal("folder mtime lost")
|
||||||
|
}
|
||||||
|
if left, _ := filepath.Glob(filepath.Join(root, "dst", ".*hamlab-moving*")); len(left) != 0 {
|
||||||
|
t.Fatal("temporary copy left behind")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "moved ") || !strings.Contains(out.String(), "4 files") {
|
||||||
|
t.Fatalf("progress said: %q", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failure part-way leaves the original as it was and no partial copy.
|
||||||
|
func TestMoveByCopyFailsClean(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "src")
|
||||||
|
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "a"), []byte("a"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := syscall.Mkfifo(filepath.Join(src, "pipe"), 0o644); err != nil {
|
||||||
|
t.Skip("no fifos here")
|
||||||
|
}
|
||||||
|
dst := filepath.Join(root, "dst")
|
||||||
|
err := moveByCopy(context.Background(), src, dst, &strings.Builder{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "pipe") {
|
||||||
|
t.Fatalf("expected the pipe to be refused, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
||||||
|
t.Fatal("the original was touched")
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("a partial copy was left")
|
||||||
|
}
|
||||||
|
if left, _ := filepath.Glob(filepath.Join(root, ".*hamlab-moving*")); len(left) != 0 {
|
||||||
|
t.Fatal("temporary copy left behind")
|
||||||
|
}
|
||||||
|
// a cancel is the same story
|
||||||
|
if err := os.Remove(filepath.Join(src, "pipe")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if err := moveByCopy(ctx, src, dst, &strings.Builder{}); err == nil {
|
||||||
|
t.Fatal("a cancelled move must fail")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
||||||
|
t.Fatal("the original was touched by a cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move on one filesystem is a rename: nothing is copied, and the guards hold.
|
||||||
|
func TestMoveSameFilesystem(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "f")
|
||||||
|
if err := os.WriteFile(src, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var l Local
|
||||||
|
var out strings.Builder
|
||||||
|
if err := l.Move(context.Background(), src, filepath.Join(root, "g"), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out.Len() != 0 {
|
||||||
|
t.Fatal("a rename has nothing to say")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "g")); err != nil {
|
||||||
|
t.Fatal("not renamed")
|
||||||
|
}
|
||||||
|
if err := l.Move(context.Background(), root, filepath.Join(root, "inside"), &out); err == nil {
|
||||||
|
t.Fatal("into itself")
|
||||||
|
}
|
||||||
|
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "g"), &out); err == nil {
|
||||||
|
t.Fatal("onto itself")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "h"), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "h"), &out); err == nil || !strings.Contains(err.Error(), "exists") {
|
||||||
|
t.Fatalf("onto an existing file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-4
@@ -1,5 +1,5 @@
|
|||||||
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, FileEntry, FileInfo, ThumbStatus } from './api'
|
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
||||||
import { fmtBytes } from './fmt'
|
import { fmtBytes } from './fmt'
|
||||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
@@ -25,6 +25,95 @@ function icon(e: FileEntry): string {
|
|||||||
|
|
||||||
type Sort = 'name' | 'size' | 'mtime'
|
type Sort = 'name' | 'size' | 'mtime'
|
||||||
const PREVIEWS_KEY = 'hamlab.files.previews'
|
const PREVIEWS_KEY = 'hamlab.files.previews'
|
||||||
|
const byName = (a: FileEntry, b: FileEntry) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||||
|
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||||
|
|
||||||
|
// ---- move: walk to the destination, then "Move here" ----
|
||||||
|
function MovePicker({ item, from, sudo, hidden, onClose, onMoved }: { item: FileEntry; from: string; sudo: boolean; hidden: boolean; onClose: () => void; onMoved: (task?: TaskInfo) => void }) {
|
||||||
|
const src = join(from, item.name)
|
||||||
|
const isFolder = item.type === 'dir' && !item.target
|
||||||
|
const [hist, setHist] = useState<string[]>([from]) // where the picker has been, for back and forward
|
||||||
|
const [at, setAt] = useState(0)
|
||||||
|
const dir = hist[at]
|
||||||
|
const [entries, setEntries] = useState<FileEntry[] | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [pathInput, setPathInput] = useState(from)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const load = useCallback(async (p: string) => {
|
||||||
|
setEntries(null); setErr('')
|
||||||
|
try { const l = await api.files(p, sudo); setEntries(l.entries) } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setEntries([]) }
|
||||||
|
}, [sudo])
|
||||||
|
useEffect(() => { setPathInput(dir); load(dir) }, [dir, load])
|
||||||
|
useEscape(onClose)
|
||||||
|
const go = (p: string) => {
|
||||||
|
p = p.trim().replace(/\/+$/, '') || '/'
|
||||||
|
if (!p.startsWith('/') || p === dir) return
|
||||||
|
setHist((h) => [...h.slice(0, at + 1), p]); setAt(at + 1)
|
||||||
|
}
|
||||||
|
const up = () => { if (dir !== '/') go(dir.slice(0, dir.lastIndexOf('/')) || '/') }
|
||||||
|
const folders = (entries ?? []).filter((e) => e.type === 'dir' && (hidden || !e.name.startsWith('.'))).sort(byName)
|
||||||
|
const crumbs = dir === '/' ? [] : dir.slice(1).split('/')
|
||||||
|
const dest = join(dir, item.name)
|
||||||
|
const why = dir === from ? 'it is already here'
|
||||||
|
: isFolder && (dir === src || dir.startsWith(src + '/')) ? 'a folder can\'t move into itself'
|
||||||
|
: (entries ?? []).some((e) => e.name === item.name) ? `something called ${item.name} is already there` : ''
|
||||||
|
const move = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const t = await api.fileMove(src, dest, sudo)
|
||||||
|
if (t) toast(`${dir} is on another filesystem — copying ${item.name} there, then removing it here`, 'info')
|
||||||
|
else toast(`moved ${item.name} to ${dir}`)
|
||||||
|
onMoved(t)
|
||||||
|
} catch (e) { toast(`move — ${e instanceof Error ? e.message : 'failed'}`, 'err'); setBusy(false) }
|
||||||
|
}
|
||||||
|
const mkdir = async () => {
|
||||||
|
const n = await askText('New folder name')
|
||||||
|
if (!okName(n)) return
|
||||||
|
try { await api.fileMkdir(join(dir, n.trim()), sudo); go(join(dir, n.trim())) } catch (e) { toast(`create folder — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<div className="modal mover" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Move {isFolder ? 'folder ' : ''}<span className="mono">{item.name}</span></h3>
|
||||||
|
<div className="mover-nav">
|
||||||
|
<button className="icon-btn" title="back" disabled={at === 0} onClick={() => setAt(at - 1)}>‹</button>
|
||||||
|
<button className="icon-btn" title="forward" disabled={at >= hist.length - 1} onClick={() => setAt(at + 1)}>›</button>
|
||||||
|
<button className="icon-btn" title="up one level" disabled={dir === '/'} onClick={up}>↑</button>
|
||||||
|
<form className="crumbs" onSubmit={(e) => { e.preventDefault(); go(pathInput) }}>
|
||||||
|
<button type="button" className="crumb" onClick={() => go('/')}>/</button>
|
||||||
|
{crumbs.map((c, i) => <button type="button" key={i} className="crumb" onClick={() => go('/' + crumbs.slice(0, i + 1).join('/'))}>{c}</button>)}
|
||||||
|
<input className="path-input" value={pathInput} onChange={(e) => setPathInput(e.target.value)} onBlur={() => setPathInput(dir)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="mover-list">
|
||||||
|
{entries === null && <Loading label="Listing…" />}
|
||||||
|
{err && <div className="error" style={{ margin: 10 }}>{err}</div>}
|
||||||
|
{folders.map((e) => {
|
||||||
|
const self = dir === from && e.name === item.name
|
||||||
|
return (
|
||||||
|
<div key={e.name} className={'mover-row' + (self ? ' self' : '')} onClick={() => { if (!self) go(join(dir, e.name)) }}>
|
||||||
|
<span className="ficon">{e.target ? '🔗' : '📁'}</span>
|
||||||
|
<span className="fname">{e.name}</span>
|
||||||
|
{self && <span className="muted small">this is what's moving</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{entries !== null && folders.length === 0 && !err && <div className="muted small" style={{ padding: 12 }}>No folders here — “Move here” puts it in this one.</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-foot">
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button className="btn" onClick={mkdir}>+ folder</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
<Btn className="btn on" disabled={!!why || entries === null} busy={busy} onClick={move}>Move here</Btn>
|
||||||
|
</div>
|
||||||
|
<div className="muted small mono mover-dest">{why || `→ ${dest}`}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- previews ----
|
// ---- previews ----
|
||||||
|
|
||||||
@@ -184,6 +273,8 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
const [pathInput, setPathInput] = useState(path)
|
const [pathInput, setPathInput] = useState(path)
|
||||||
const [open, setOpen] = useState<string | null>(null)
|
const [open, setOpen] = useState<string | null>(null)
|
||||||
const [menu, setMenu] = useState<string | null>(null)
|
const [menu, setMenu] = useState<string | null>(null)
|
||||||
|
const [moving, setMoving] = useState<FileEntry | null>(null)
|
||||||
|
const [moveTask, setMoveTask] = useState<TaskInfo | null>(null)
|
||||||
const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null)
|
const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null)
|
||||||
const [drag, setDrag] = useState(false)
|
const [drag, setDrag] = useState(false)
|
||||||
const fileInput = useRef<HTMLInputElement>(null)
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
@@ -227,12 +318,10 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
|
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
|
||||||
try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
|
try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
|
||||||
}
|
}
|
||||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
|
||||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
|
||||||
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||||
const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists
|
const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists
|
||||||
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||||
const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
const move = (e: FileEntry) => setMoving(e)
|
||||||
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
||||||
const del = async (e: FileEntry) => {
|
const del = async (e: FileEntry) => {
|
||||||
const full = join(path, e.name)
|
const full = join(path, e.name)
|
||||||
@@ -292,6 +381,8 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previews && <ThumbNote st={thumbs} sudo={sudo} onChanged={() => readThumbs(true)} />}
|
{previews && <ThumbNote st={thumbs} sudo={sudo} onChanged={() => readThumbs(true)} />}
|
||||||
|
{moveTask && <TaskOutput id={moveTask.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${moveTask.title}: done`); else toast(`${moveTask.title}: failed`, 'err') }} />}
|
||||||
|
{moveTask && <div className="thumb-note"><button className="btn" onClick={() => setMoveTask(null)}>Hide output</button></div>}
|
||||||
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
|
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
|
||||||
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
|
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
|
||||||
|
|
||||||
@@ -330,6 +421,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
{entries !== null && shown.length === 0 && !err && <div className="muted" style={{ padding: 12 }}>{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}</div>}
|
{entries !== null && shown.length === 0 && !err && <div className="muted" style={{ padding: 12 }}>{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}</div>}
|
||||||
</div>
|
</div>
|
||||||
{drag && <div className="drop-hint">Drop to upload into {path}</div>}
|
{drag && <div className="drop-hint">Drop to upload into {path}</div>}
|
||||||
|
{moving && <MovePicker item={moving} from={path} sudo={sudo} hidden={hidden} onClose={() => setMoving(null)} onMoved={(t) => { setMoving(null); if (t) setMoveTask(t); load() }} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,6 +316,8 @@ export const api = {
|
|||||||
fileMkdir: (path: string, sudo: boolean) => req<void>('POST', `/api/files/mkdir${sudo ? '?sudo=1' : ''}`, { path }),
|
fileMkdir: (path: string, sudo: boolean) => req<void>('POST', `/api/files/mkdir${sudo ? '?sudo=1' : ''}`, { path }),
|
||||||
fileTouch: (path: string, sudo: boolean) => req<void>('POST', `/api/files/touch${sudo ? '?sudo=1' : ''}`, { path }),
|
fileTouch: (path: string, sudo: boolean) => req<void>('POST', `/api/files/touch${sudo ? '?sudo=1' : ''}`, { path }),
|
||||||
fileRename: (path: string, to: string, sudo: boolean) => req<void>('POST', `/api/files/rename${sudo ? '?sudo=1' : ''}`, { path, to }),
|
fileRename: (path: string, to: string, sudo: boolean) => req<void>('POST', `/api/files/rename${sudo ? '?sudo=1' : ''}`, { path, to }),
|
||||||
|
// a rename when the ends share a filesystem (nothing back), otherwise the copy-and-remove runs as a task (the task back)
|
||||||
|
fileMove: (path: string, to: string, sudo: boolean) => req<TaskInfo | undefined>('POST', `/api/files/move${sudo ? '?sudo=1' : ''}`, { path, to }),
|
||||||
fileDelete: (path: string, recursive: boolean, sudo: boolean) => req<void>('POST', `/api/files/delete${sudo ? '?sudo=1' : ''}`, { path, recursive }),
|
fileDelete: (path: string, recursive: boolean, sudo: boolean) => req<void>('POST', `/api/files/delete${sudo ? '?sudo=1' : ''}`, { path, recursive }),
|
||||||
fileChmod: (path: string, mode: string, sudo: boolean) => req<void>('POST', `/api/files/chmod${sudo ? '?sudo=1' : ''}`, { path, mode }),
|
fileChmod: (path: string, mode: string, sudo: boolean) => req<void>('POST', `/api/files/chmod${sudo ? '?sudo=1' : ''}`, { path, mode }),
|
||||||
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
||||||
|
|||||||
@@ -371,6 +371,18 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.fthumb.video::after { content: '▶'; position: absolute; right: 2px; bottom: 2px; font-size: 9px; line-height: 1; padding: 2px 3px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; }
|
.fthumb.video::after { content: '▶'; position: absolute; right: 2px; bottom: 2px; font-size: 9px; line-height: 1; padding: 2px 3px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; }
|
||||||
.fthumb .ficon { font-size: 18px; }
|
.fthumb .ficon { font-size: 18px; }
|
||||||
.thumb-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 10px 0; }
|
.thumb-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 10px 0; }
|
||||||
|
/* move: the folder picker */
|
||||||
|
.modal.mover { width: min(560px, 100%); height: min(76vh, 100%); overflow: hidden; }
|
||||||
|
.mover-nav { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.mover-nav .icon-btn { font-size: 18px; min-width: 30px; line-height: 1; }
|
||||||
|
.mover-nav .icon-btn:disabled { opacity: .3; }
|
||||||
|
.mover-nav .crumbs { flex: 1; min-width: 0; margin-left: 4px; }
|
||||||
|
.modal.mover > .mover-list { flex: 1 1 auto; min-height: 80px; overflow-y: auto; margin: 0 -16px; border-top: 1px solid var(--border); }
|
||||||
|
.mover-row { display: flex; align-items: center; gap: 8px; padding: 9px 16px; cursor: pointer; border-bottom: 1px solid var(--border); min-height: 38px; }
|
||||||
|
.mover-row:hover { background: var(--bg3); }
|
||||||
|
.mover-row.self { opacity: .5; cursor: default; }
|
||||||
|
.mover-row .fname { flex: 1; }
|
||||||
|
.mover-dest { overflow-wrap: anywhere; }
|
||||||
.thumb-note .btn { padding-top: 3px; padding-bottom: 3px; }
|
.thumb-note .btn { padding-top: 3px; padding-bottom: 3px; }
|
||||||
.files-bar .task-out { margin: 6px 10px 0; max-height: 160px; }
|
.files-bar .task-out { margin: 6px 10px 0; max-height: 160px; }
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user