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:
redtea committed 2026-10-15 21:42:21 +03:00
1 parent a30b89f1d8
commit f2740c50b4
12 files changed
+484 -32

No files matched your search

+48 -10
View File
@@ -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.
+102
View File
@@ -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
}
+61
View File
@@ -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)
}
}