diff --git a/internal/api/server.go b/internal/api/server.go index 76d1f87..722ae59 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/files/files.go b/internal/files/files.go index 0f969fc..c124d03 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -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 …")) + return fail(errors.New("usage: hamlab fs …")) } 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) } diff --git a/internal/files/move.go b/internal/files/move.go new file mode 100644 index 0000000..0df02ca --- /dev/null +++ b/internal/files/move.go @@ -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)) + } +} diff --git a/internal/files/move_test.go b/internal/files/move_test.go new file mode 100644 index 0000000..eb4f085 --- /dev/null +++ b/internal/files/move_test.go @@ -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) + } +} diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 6fdf95f..f6c3cc9 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -1,5 +1,5 @@ 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 { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui' import { useHashSel } from './hashsel' @@ -25,6 +25,95 @@ function icon(e: FileEntry): string { type Sort = 'name' | 'size' | 'mtime' 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([from]) // where the picker has been, for back and forward + const [at, setAt] = useState(0) + const dir = hist[at] + const [entries, setEntries] = useState(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 ( +
+
e.stopPropagation()}> +

Move {isFolder ? 'folder ' : ''}{item.name}

+
+ + + +
{ e.preventDefault(); go(pathInput) }}> + + {crumbs.map((c, i) => )} + setPathInput(e.target.value)} onBlur={() => setPathInput(dir)} spellCheck={false} autoCapitalize="off" autoCorrect="off" /> +
+
+
+ {entries === null && } + {err &&
{err}
} + {folders.map((e) => { + const self = dir === from && e.name === item.name + return ( +
{ if (!self) go(join(dir, e.name)) }}> + {e.target ? '🔗' : '📁'} + {e.name} + {self && this is what's moving} +
+ ) + })} + {entries !== null && folders.length === 0 && !err &&
No folders here — “Move here” puts it in this one.
} +
+
+
+ + + + Move here +
+
{why || `→ ${dest}`}
+
+
+
+ ) +} // ---- previews ---- @@ -184,6 +273,8 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s const [pathInput, setPathInput] = useState(path) const [open, setOpen] = useState(null) const [menu, setMenu] = useState(null) + const [moving, setMoving] = useState(null) + const [moveTask, setMoveTask] = useState(null) const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null) const [drag, setDrag] = useState(false) const fileInput = useRef(null) @@ -227,12 +318,10 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s const act = async (label: string, fn: () => Promise): Promise => { 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 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 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 del = async (e: FileEntry) => { const full = join(path, e.name) @@ -292,6 +381,8 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s {previews && readThumbs(true)} />} + {moveTask && { load(); if (t.exit_code === 0 && !t.error) toast(`${moveTask.title}: done`); else toast(`${moveTask.title}: failed`, 'err') }} />} + {moveTask &&
} {upload &&
{upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%
} {err &&
{err}{/permission denied/i.test(err) && !sudo && <> · }
} @@ -330,6 +421,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s {entries !== null && shown.length === 0 && !err &&
{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}
}
{drag &&
Drop to upload into {path}
} + {moving &&