feat: drag files between file panes (move or copy, SSH hosts too) and into a terminal of the same machine
This commit is contained in:
1 parent
a30b89f1d8
commit
f2740c50b4
12 files changed
+484
-32
No files matched your search
+48
-10
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user