diff --git a/internal/agent/ship.go b/internal/agent/ship.go index 05cfaba..b4ff13a 100644 --- a/internal/agent/ship.go +++ b/internal/agent/ship.go @@ -179,7 +179,15 @@ func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto. return proto.TaskResult{Error: err.Error()} } src := filepath.Clean(args.Src) - if _, err := os.Lstat(src); err != nil { + var conn *sshfsConn + if args.Conn != "" { + // The files are on an SSH host this agent holds a connection to. + c, err := sshfsGet(args.Conn) + if err != nil { + return proto.TaskResult{Error: err.Error()} + } + conn, src = c, path.Clean(args.Src) + } else if _, err := os.Lstat(src); err != nil { return proto.TaskResult{Error: err.Error()} } ctx, cancel := context.WithCancel(parent) @@ -187,14 +195,22 @@ func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto. m := &shipMeter{} m.note.Store("подсчёт") go a.reportShip(ctx, args.Job, m, cancel) - m.total.Store(treeSize(ctx, src)) + if conn != nil { + m.total.Store(a.remoteSize(conn, src)) + } else { + m.total.Store(treeSize(ctx, src)) + } + pack := func(w io.Writer) error { return packTar(ctx, w, src, args.Name, m) } + if conn != nil { + pack = func(w io.Writer) error { return a.packRemote(ctx, w, conn, src, args.Name, m) } + } var err error if args.SSH != nil { err = a.shipSSH(ctx, args, src, m) } else { m.note.Store("") - err = a.shipRelay(ctx, args, src, m) + err = a.shipRelay(ctx, args, pack) } if err != nil { if ctx.Err() != nil && parent.Err() == nil { @@ -218,14 +234,14 @@ func (a *Agent) relayURL(id string, i int) string { // shipRelay posts the tar stream in pieces, a few at once; the last carries // X-Last. -func (a *Agent) shipRelay(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error { +func (a *Agent) shipRelay(ctx context.Context, args proto.FSShipArgs, pack func(io.Writer) error) error { if args.Relay == "" || args.Chunk <= 0 { return errors.New("не задана передача") } ctx, cancel := context.WithCancel(ctx) defer cancel() pr, pw := io.Pipe() - go func() { pw.CloseWithError(packTar(ctx, pw, src, args.Name, m)) }() + go func() { pw.CloseWithError(pack(pw)) }() defer pr.Close() type piece struct { @@ -306,11 +322,21 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task return proto.TaskResult{Error: err.Error()} } dir := filepath.Clean(args.Dir) - if _, err := os.Lstat(filepath.Join(dir, args.Name)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" { - return proto.TaskResult{Error: filepath.Join(dir, args.Name) + " уже существует"} - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return proto.TaskResult{Error: err.Error()} + var conn *sshfsConn + if args.Conn != "" { + // Into an SSH host this agent holds a connection to. + c, err := sshfsGet(args.Conn) + if err != nil { + return proto.TaskResult{Error: err.Error()} + } + conn, dir = c, path.Clean(args.Dir) + } else { + if _, err := os.Lstat(filepath.Join(dir, args.Name)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" { + return proto.TaskResult{Error: filepath.Join(dir, args.Name) + " уже существует"} + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return proto.TaskResult{Error: err.Error()} + } } ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -339,6 +365,18 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task } } }() + if conn != nil { + err := a.unpackRemote(conn, pr, dir, args.Name, args.Conflict) + if err == nil { + _, _ = io.Copy(io.Discard, pr) + } + pr.CloseWithError(errors.New("распаковка закончена")) + if err != nil { + return proto.TaskResult{Error: err.Error()} + } + data, _ := json.Marshal(map[string]int{}) + return proto.TaskResult{OK: true, Data: data} + } u := &unpacker{dir: dir, name: args.Name, conflict: args.Conflict} if os.Geteuid() == 0 { // New things belong to whoever owns the folder they land in. diff --git a/internal/agent/sshfs.go b/internal/agent/sshfs.go index 127123d..3abf8aa 100644 --- a/internal/agent/sshfs.go +++ b/internal/agent/sshfs.go @@ -7,6 +7,7 @@ package agent // for sshfsIdle is closed. import ( + "archive/tar" "bytes" "context" "crypto/rand" @@ -523,3 +524,104 @@ func (a *Agent) doSSHFSPut(ctx context.Context, env proto.TaskEnvelope) proto.Ta data, _ := json.Marshal(map[string]any{"path": args.Path, "size": args.Size}) return proto.TaskResult{OK: true, Data: data} } + +// renameTar copies a tar stream, giving its top entry base - and everything +// under it - the name name: what the host's tar packs under the file's own +// name arrives under the name chosen for it. Entries other than base (the +// "._" companions a macOS tar adds) are left out. +func renameTar(w io.Writer, r io.Reader, base, name string) error { + tr, tw := tar.NewReader(r), tar.NewWriter(w) + for { + h, err := tr.Next() + if err == io.EOF { + return tw.Close() + } + if err != nil { + return err + } + n := strings.TrimPrefix(h.Name, "./") + top, rest := n, "" + if i := strings.IndexByte(n, '/'); i >= 0 { + top, rest = n[:i], n[i:] + } + if top != base { + continue + } + h.Name = name + rest + h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", "" + if err := tw.WriteHeader(h); err != nil { + return err + } + if _, err := io.Copy(tw, tr); err != nil { + return err + } + } +} + +// packRemote writes src on the connection's host as a tar stream named name, +// counting what goes for the progress. +func (a *Agent) packRemote(ctx context.Context, w io.Writer, c *sshfsConn, src, name string, m *shipMeter) error { + s, err := a.sshfsSession(c) + if err != nil { + return err + } + defer s.Close() + go func() { <-ctx.Done(); s.Close() }() + out, _ := s.StdoutPipe() + var errb bytes.Buffer + s.Stderr = &limitBuf{b: &errb, max: 4096} + src = path.Clean(src) + if err := s.Start("COPYFILE_DISABLE=1 tar -cf - -C " + shq(path.Dir(src)) + " -- " + shq(path.Base(src))); err != nil { + return err + } + if err := renameTar(w, &countReader{r: out, n: &m.done}, path.Base(src), name); err != nil { + return err + } + if err := s.Wait(); err != nil { + if msg := strings.TrimSpace(errb.String()); msg != "" { + return errors.New("tar на хосте: " + msg) + } + return err + } + return nil +} + +// remoteSize is about how many bytes src takes on the host, for progress. +func (a *Agent) remoteSize(c *sshfsConn, src string) int64 { + out, err := a.sshfsRun(c, "du -sk -- "+shq(src), nil) + if err != nil { + return 0 + } + f := strings.Fields(string(out)) + if len(f) == 0 { + return 0 + } + kb, _ := strconv.ParseInt(f[0], 10, 64) + return kb * 1024 +} + +// unpackRemote puts a tar stream into dir on the connection's host, by the +// conflict rule: tar keeps or replaces what is there, an "error" rule +// refuses when name is there already. +func (a *Agent) unpackRemote(c *sshfsConn, r io.Reader, dir, name, conflict string) error { + target := strings.TrimSuffix(dir, "/") + "/" + name + out, _ := a.sshfsRun(c, "test -e "+shq(target)+" -o -L "+shq(target)+" && echo LPRA_EXISTS; tar --version 2>/dev/null | head -n 1", nil) + if strings.Contains(string(out), "LPRA_EXISTS") && conflict != "replace" && conflict != "skip" { + return errors.New(target + " уже существует") + } + gnu := strings.Contains(string(out), "GNU tar") + flags := "" + switch conflict { + case "skip": + flags = " -k" + if gnu { + flags = " --skip-old-files" + } + case "replace": + if gnu { + flags = " --overwrite" + } + } + _, err := a.sshfsRun(c, "mkdir -p -- "+shq(dir)+" && cd -- "+shq(dir)+" && tar -xf -"+flags, r) + return err +} diff --git a/internal/agent/sshfs_test.go b/internal/agent/sshfs_test.go index 327b378..58cd04e 100644 --- a/internal/agent/sshfs_test.go +++ b/internal/agent/sshfs_test.go @@ -1,6 +1,9 @@ package agent import ( + "archive/tar" + "bytes" + "io" "strings" "testing" ) @@ -45,3 +48,61 @@ func TestParseLsLine(t *testing.T) { t.Fatal("total parsed") } } + +func TestRenameTarEntries(t *testing.T) { + var src bytes.Buffer + tw := tar.NewWriter(&src) + for _, n := range []string{"old/", "old/a.txt", "old/sub/b"} { + typ := byte(tar.TypeReg) + if strings.HasSuffix(n, "/") { + typ = tar.TypeDir + } + _ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: typ}) + } + _ = tw.Close() + var out bytes.Buffer + if err := renameTar(&out, &src, "old", "new"); err != nil { + t.Fatal(err) + } + tr := tar.NewReader(&out) + var names []string + for { + h, err := tr.Next() + if err != nil { + break + } + names = append(names, h.Name) + } + if strings.Join(names, ",") != "new/,new/a.txt,new/sub/b" { + t.Fatalf("names: %v", names) + } +} + +// A macOS host's tar packs "._name" companions with the file's attributes; +// they are not the file and must not land under its name. +func TestRenameTarDropsOtherEntries(t *testing.T) { + var src bytes.Buffer + tw := tar.NewWriter(&src) + for _, e := range []struct{ n, body string }{{"./._r.txt", "attrs"}, {"./r.txt", "data"}} { + _ = tw.WriteHeader(&tar.Header{Name: e.n, Mode: 0o644, Typeflag: tar.TypeReg, Size: int64(len(e.body))}) + _, _ = tw.Write([]byte(e.body)) + } + _ = tw.Close() + var out bytes.Buffer + if err := renameTar(&out, &src, "r.txt", "new.txt"); err != nil { + t.Fatal(err) + } + tr := tar.NewReader(&out) + var got []string + for { + h, err := tr.Next() + if err != nil { + break + } + b, _ := io.ReadAll(tr) + got = append(got, h.Name+"="+string(b)) + } + if strings.Join(got, ",") != "new.txt=data" { + t.Fatalf("entries: %v", got) + } +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index e8045fe..fce4ab5 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -366,6 +366,9 @@ type FSShipArgs struct { Chunk int `json:"chunk,omitempty"` Job string `json:"job,omitempty"` SSH *SSHTarget `json:"ssh,omitempty"` + // Conn: the files of this side are on the SSH host of this agent's file + // connection (an SSH file pane), not on the machine itself. + Conn string `json:"conn,omitempty"` } // ShipProgress is what a sending machine reports: bytes of files sent out of diff --git a/internal/server/ship.go b/internal/server/ship.go index 52d1b3e..eaa1e31 100644 --- a/internal/server/ship.go +++ b/internal/server/ship.go @@ -115,6 +115,10 @@ type shipReq struct { Move bool `json:"move"` To string `json:"to"` SSH *proto.SSHTarget `json:"ssh"` + // FromConn, ToConn: that side is an SSH host held by its agent for a + // file pane (its connection id); To is still the agent holding ToConn. + FromConn string `json:"from_conn"` + ToConn string `json:"to_conn"` } // POST /api/panel/agents/{id}/fs/ship - start sending; the answer names the @@ -147,6 +151,9 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) { } where = "ssh:" + t.User + "@" + t.Host + ":" + strconv.Itoa(t.Port) } else { + if q.To == "" && q.ToConn != "" { + q.To = from + } if q.To == "" { writeJSON(w, proto.TaskResult{Error: "не выбрана машина"}) return @@ -224,12 +231,14 @@ func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) }() go func() { <-ctx.Done(); x.fail("отменено") }() args.Relay, args.Chunk = x.id, xferChunk + inArgs := args + args.Conn, inArgs.Conn = q.FromConn, q.ToConn var in proto.TaskResult inDone := make(chan struct{}) go func() { defer close(inDone) - res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, args, xferTimeout) + res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, inArgs, xferTimeout) if err != nil { res = proto.TaskResult{Error: err.Error()} } @@ -254,7 +263,12 @@ func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) return out } if q.Move { - res, err := s.waitTask(ctx, from, proto.TaskFSDelTree, proto.FSPathArgs{Path: q.Src}, fsTaskTimeout) + task, del := proto.TaskFSDelTree, any(proto.FSPathArgs{Path: q.Src}) + if q.FromConn != "" { + raw, _ := json.Marshal(proto.FSPathArgs{Path: q.Src}) + task, del = proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: q.FromConn, Op: "deltree", Args: raw} + } + res, err := s.waitTask(ctx, from, task, del, fsTaskTimeout) if err == nil && !res.OK { err = errText(res.Error) } diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 33a34db..2cd38b9 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -342,6 +342,8 @@ body.split1 .float-add { top:42px; right:16px; } body.multi .float-add { opacity:0; pointer-events:none; } .tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995); transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; } +/* A tab fading out takes no pointer: a drop goes to the tab coming in. */ +.tabview:not(.on) { pointer-events:none; } .tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; } .pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden; transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; } @@ -373,6 +375,18 @@ body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; } box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; } .drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); } body.dnd, body.dnd * { cursor:grabbing !important; } +/* Files dragged out of a pane: the target's outline, and the small window + where they were dropped. */ +.hint.drophint { position:fixed; z-index:55; transition:none; } +.dropdlg { position:fixed; z-index:56; width:300px; max-width:calc(100vw - 16px); padding:12px; display:flex; flex-direction:column; gap:8px; + background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55); font-size:13px; } +.dropdlg[hidden] { display:none; } +.dropdlg .dd-what { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.dropdlg .dd-to { overflow-wrap:anywhere; } +.dropdlg .dd-c { display:flex; align-items:center; gap:6px; } +.dropdlg .mv-prog { margin-top:0; } +.dropdlg .dd-btns { display:flex; gap:6px; justify-content:flex-end; } +.fpane .fp-list tr[data-name] { user-select:none; } .empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); } .empty[hidden] { display:none; } /* The "+" menu: a terminal here, or on another machine. */ diff --git a/internal/server/web/static/dnd.js b/internal/server/web/static/dnd.js new file mode 100644 index 0000000..853a937 --- /dev/null +++ b/internal/server/web/static/dnd.js @@ -0,0 +1,196 @@ +// Dragging files out of a file pane: onto another file pane or a folder in +// it to move or copy them there (between machines and SSH hosts too, by the +// transfer the move dialog uses), or into a terminal of the same place to +// paste their paths. A terminal of another place refuses them, red, with +// the reason. Held over a tab's title for half a second, the tab opens to +// drop into. Esc or empty space cancels. Computers only. +(function () { + 'use strict'; + if (!Workspace.desk) return; + const FV = window.FileViews; + const {toast, join, esc} = FV; + let d = null, justDropped = false; + + const box = document.createElement('div'); + box.className = 'hint drophint'; + box.hidden = true; + document.body.appendChild(box); + + // start: a pointerdown on a row of pane; the drag begins past 6px. + function start(ev, pane, tr) { + if (ev.button !== 0 || ev.target.closest('input, button, a, .act')) return; + const name = tr.dataset.name; + const names = pane.selected.has(name) ? [...pane.selected] : [name]; + d = {pane, names, x0: ev.clientX, y0: ev.clientY, id: ev.pointerId, on: false, + item: {source: pane.source, dir: pane.cwd, paths: names.map(n => join(pane.cwd, n))}}; + } + function begin() { + d.on = true; + const g = d.ghost = document.createElement('div'); + g.className = 'drag-ghost'; + g.textContent = d.names.length === 1 ? d.names[0] : d.names.length + ' элементов'; + document.body.appendChild(g); + document.body.classList.add('dnd'); + getSelection().removeAllRanges(); + } + function show(r, label, bad) { + box.hidden = false; + box.classList.toggle('bad', !!bad); + box.style.left = r.left + 'px'; box.style.top = r.top + 'px'; + box.style.width = r.width + 'px'; box.style.height = r.height + 'px'; + box.textContent = label; + } + // inside: dropping folder p into itself or below it. + const inside = (dir, p) => dir === p || dir.startsWith(p.replace(/\/+$/, '') + '/'); + + function move(ev) { + if (!d || ev.pointerId !== d.id) return; + if (!d.on) { + if (Math.hypot(ev.clientX - d.x0, ev.clientY - d.y0) < 6) return; + begin(); + } + d.ghost.style.transform = 'translate(' + (ev.clientX + 12) + 'px,' + (ev.clientY + 10) + 'px)'; + d.target = null; + box.hidden = true; + d.ghost.dataset.hint = ''; + // Over a tab's title: it opens after half a second. + const tab = Workspace.tabAt(ev.clientX, ev.clientY); + if (tab !== d.overTab) { + clearTimeout(d.tabTimer); + d.overTab = tab; + if (tab) d.tabTimer = setTimeout(() => { if (d && d.overTab === tab) Workspace.activate(tab); }, 500); + } + if (tab) { d.ghost.dataset.hint = 'открыть вкладку'; return; } + const pane = Workspace.paneAt(ev.clientX, ev.clientY); + if (!pane || !pane.dropTarget) return; + const t = pane.dropTarget(ev.clientX, ev.clientY); + const a = WSUtil.dropAction(d.item, t); + const r = t.kind === 'folder' ? t.row.getBoundingClientRect() : pane.el.getBoundingClientRect(); + if (a.kind === 'deny') { show(r, a.reason, true); return; } + if (a.kind === 'none') return; + if (a.kind === 'transfer' && t.source.id === d.item.source.id && d.item.paths.some(p => inside(t.dir, p))) { + show(r, 'папку не положить в саму себя', true); + return; + } + show(r, a.kind === 'paste' ? 'вставить путь' : 'сюда: ' + t.dir); + d.target = {pane, t, a}; + } + function end(ev, cancel) { + if (!d || (ev && ev.pointerId !== d.id)) return; + const x = d; + d = null; + clearTimeout(x.tabTimer); + if (!x.on) return; + x.ghost.remove(); + box.hidden = true; + document.body.classList.remove('dnd'); + justDropped = true; + setTimeout(() => { justDropped = false; }, 0); + if (cancel || !x.target) return; + const {pane, t, a} = x.target; + Workspace.setActivePane(pane); + if (a.kind === 'paste') pane.drop(x.item); + else if (a.kind === 'transfer') ask(x.item, x.pane, t, ev.clientX, ev.clientY); + } + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', ev => end(ev)); + window.addEventListener('pointercancel', ev => end(ev, true)); + window.addEventListener('keydown', ev => { if (ev.key === 'Escape' && d) end(null, true); }); + // A drop is not a click on the row it started from. + document.addEventListener('click', ev => { if (justDropped) { ev.stopPropagation(); ev.preventDefault(); } }, true); + + // ---------- the small window where the files were dropped ---------- + const dlg = document.createElement('div'); + dlg.className = 'dropdlg'; + dlg.hidden = true; + dlg.innerHTML = + '
' + + '' + + '