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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+16
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 =
|
||||
'<div class="dd-what"></div><div class="dd-to muted"></div>' +
|
||||
'<label class="dd-c">если уже есть: <select class="dd-conflict">' +
|
||||
'<option value="error">сообщить</option><option value="replace">заменить</option><option value="skip">оставить</option></select></label>' +
|
||||
'<div class="mv-prog dd-prog" hidden><div class="mv-bar"><i class="dd-bar"></i></div><div class="muted dd-ptext"></div></div>' +
|
||||
'<div class="dd-btns"><button type="button" class="btn small primary dd-move">Переместить</button>' +
|
||||
'<button type="button" class="btn small dd-copy">Копировать</button>' +
|
||||
'<button type="button" class="btn small ghost dd-cancel">Отмена</button></div>';
|
||||
document.body.appendChild(dlg);
|
||||
const q = s => dlg.querySelector(s);
|
||||
let cur = null;
|
||||
|
||||
function ask(item, from, t, x, y) {
|
||||
if (cur && cur.job) { toast('дождитесь конца передачи', true); return; }
|
||||
cur = {item, from, t, job: null, stop: false};
|
||||
const n = item.paths.length;
|
||||
q('.dd-what').textContent = n === 1 ? item.paths[0].split('/').pop() : n + ' элементов';
|
||||
q('.dd-to').innerHTML = '→ ' + esc(t.source.label) + ':' + esc(t.dir);
|
||||
q('.dd-conflict').value = 'error';
|
||||
q('.dd-prog').hidden = true;
|
||||
q('.dd-move').disabled = q('.dd-copy').disabled = false;
|
||||
q('.dd-cancel').textContent = 'Отмена';
|
||||
dlg.hidden = false;
|
||||
const w = dlg.offsetWidth, h = dlg.offsetHeight;
|
||||
dlg.style.left = Math.max(8, Math.min(innerWidth - w - 8, x - w / 2)) + 'px';
|
||||
dlg.style.top = Math.max(8, Math.min(innerHeight - h - 8, y + 12)) + 'px';
|
||||
q('.dd-move').focus();
|
||||
}
|
||||
function close() { dlg.hidden = true; cur = null; }
|
||||
q('.dd-cancel').onclick = () => {
|
||||
if (cur && cur.job) { cur.stop = true; fetch('/api/panel/fs/job/' + cur.job + '/cancel', {method: 'POST'}); q('.dd-ptext').textContent = 'отмена…'; return; }
|
||||
close();
|
||||
};
|
||||
dlg.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !(cur && cur.job)) close(); });
|
||||
document.addEventListener('pointerdown', ev => { if (!dlg.hidden && !dlg.contains(ev.target) && !(cur && cur.job)) close(); }, true);
|
||||
q('.dd-move').onclick = () => run(true);
|
||||
q('.dd-copy').onclick = () => run(false);
|
||||
|
||||
async function run(isMove) {
|
||||
const c = cur, {item, from, t} = c, conflict = q('.dd-conflict').value;
|
||||
q('.dd-move').disabled = q('.dd-copy').disabled = true;
|
||||
// One place (a machine, or one SSH host): copied there by its own means.
|
||||
// Between places: the transfer of the move dialog, one item at a time.
|
||||
const here = item.source.id === t.source.id;
|
||||
let done = 0, skipped = 0, err = '';
|
||||
if (!here) {
|
||||
q('.dd-prog').hidden = false;
|
||||
q('.dd-bar').style.width = '0';
|
||||
q('.dd-bar').parentNode.classList.add('wait');
|
||||
q('.dd-ptext').textContent = 'подготовка…';
|
||||
q('.dd-cancel').textContent = 'Стоп';
|
||||
}
|
||||
for (const p of item.paths) {
|
||||
if (c.stop) break;
|
||||
const name = p.split('/').pop();
|
||||
let r;
|
||||
if (here) {
|
||||
r = await from.src.op('copy', {src: p, dst: join(t.dir, name), move: isMove, conflict});
|
||||
} else {
|
||||
const body = {src: p, name, dir: t.dir, conflict, move: isMove};
|
||||
if (t.source.kind === 'ssh') { body.to = t.pane.agent.id; body.to_conn = t.pane.src.conn; }
|
||||
else body.to = t.pane.agent.id;
|
||||
if (from.kind === 'sshfiles') body.from_conn = from.src.conn;
|
||||
// The transfer is the machine's, whichever side is an SSH host.
|
||||
r = await new MachineSource(from.agent).op('ship', body);
|
||||
if (r.ok) {
|
||||
c.job = r.data.job;
|
||||
const pre = item.paths.length > 1 ? name + ': ' : '';
|
||||
r = await FV.followJob(c.job, (pr, text) => {
|
||||
if (pr.total > 0 || pr.done > 0) {
|
||||
q('.dd-bar').parentNode.classList.remove('wait');
|
||||
q('.dd-bar').style.width = (pr.total ? Math.min(100, pr.done / pr.total * 100) : 0).toFixed(1) + '%';
|
||||
}
|
||||
if (text) q('.dd-ptext').textContent = pre + text;
|
||||
});
|
||||
c.job = null;
|
||||
}
|
||||
}
|
||||
if (!r.ok) { err = r.error || 'ошибка'; break; }
|
||||
done++;
|
||||
skipped += (r.data && r.data.skipped) || 0;
|
||||
}
|
||||
close();
|
||||
from.reload();
|
||||
if (t.pane !== from) t.pane.reload();
|
||||
if (err) toast((done ? 'сделано ' + done + ' из ' + item.paths.length + '; ' : '') + err, true);
|
||||
else toast((isMove ? 'перемещено' : 'скопировано') + (item.paths.length > 1 ? ': ' + done : '') + (skipped ? ', оставлено как было: ' + skipped : ''));
|
||||
}
|
||||
|
||||
window.FileDnD = {start, get active() { return !!(d && d.on); }};
|
||||
})();
|
||||
@@ -148,7 +148,7 @@
|
||||
dropTarget(x, y) {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
const tr = el && el.closest && el.closest('tr[data-dir]');
|
||||
if (tr && this.el.contains(tr)) return {kind: 'folder', source: this.source, dir: join(this.cwd, tr.dataset.dir), pane: this};
|
||||
if (tr && this.el.contains(tr)) return {kind: 'folder', source: this.source, dir: join(this.cwd, tr.dataset.dir), pane: this, row: tr};
|
||||
return {kind: 'files', source: this.source, dir: this.cwd, pane: this};
|
||||
}
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
tr.querySelector('[data-act="ren"]').onclick = () => FV.move(this, e);
|
||||
const uz = tr.querySelector('[data-act="unzip"]');
|
||||
if (uz) uz.onclick = () => this.unzip(e);
|
||||
if (window.FileDnD) tr.addEventListener('pointerdown', ev => FileDnD.start(ev, this, tr));
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +748,31 @@
|
||||
$('mv-prog').hidden = !on;
|
||||
if (on) requestAnimationFrame(() => $('mv-prog').scrollIntoView({block: 'nearest', behavior: 'smooth'}));
|
||||
}
|
||||
// followJob waits for a transfer job to end, telling onProgress how far
|
||||
// it has got (the progress and a line about it), and gives its result.
|
||||
async function followJob(job, onProgress) {
|
||||
let v = 0;
|
||||
const seen = [];
|
||||
for (;;) {
|
||||
let j;
|
||||
try { j = await fetch('/api/panel/fs/job/' + job + '?v=' + v, {cache: 'no-store'}).then(x => x.json()); }
|
||||
catch (e) { await new Promise(ok => setTimeout(ok, 1500)); continue; }
|
||||
v = j.v || 0;
|
||||
const p = j.progress || {};
|
||||
let text = '';
|
||||
if (p.total > 0 || p.done > 0) {
|
||||
// Speed over the last few seconds.
|
||||
seen.push([Date.now(), p.done]);
|
||||
while (seen.length > 2 && Date.now() - seen[0][0] > 4000) seen.shift();
|
||||
const dt = (seen[seen.length - 1][0] - seen[0][0]) / 1000, rate = dt > 0.5 ? (seen[seen.length - 1][1] - seen[0][1]) / dt : 0;
|
||||
text = fmtSize(p.done) + ' из ' + fmtSize(p.total) + ' · файлов: ' + p.files + (rate ? ' · ' + fmtSize(Math.round(rate)) + '/с' : '');
|
||||
} else if (p.note) {
|
||||
text = p.note === 'подсчёт' ? 'подсчёт…' : p.note + '…';
|
||||
}
|
||||
onProgress(p, text);
|
||||
if (j.done) return j.result || {ok: false, error: 'ошибка'};
|
||||
}
|
||||
}
|
||||
// ship sends to the other machine and follows the job to its end.
|
||||
async function ship(dir, name, conflict, move) {
|
||||
const body = {src: M.src, name, dir, conflict, move};
|
||||
@@ -771,27 +796,14 @@
|
||||
$('mv-pw').value = ''; $('mv-pass').value = '';
|
||||
if (!r.ok) { busy(false); toast(r.error || 'ошибка', true); return; }
|
||||
const job = M.job = r.data.job;
|
||||
let v = 0, res = null;
|
||||
const t0 = Date.now(), seen = [];
|
||||
while (!res) {
|
||||
let j;
|
||||
try { j = await fetch('/api/panel/fs/job/' + job + '?v=' + v, {cache: 'no-store'}).then(x => x.json()); }
|
||||
catch (e) { await new Promise(ok => setTimeout(ok, 1500)); continue; }
|
||||
v = j.v || 0;
|
||||
const p = j.progress || {};
|
||||
const t0 = Date.now();
|
||||
const res = await followJob(job, (p, text) => {
|
||||
if (p.total > 0 || p.done > 0) {
|
||||
$('mv-bar').parentNode.classList.remove('wait');
|
||||
$('mv-bar').style.width = (p.total ? Math.min(100, p.done / p.total * 100) : 0).toFixed(1) + '%';
|
||||
// Speed over the last few seconds.
|
||||
seen.push([Date.now(), p.done]);
|
||||
while (seen.length > 2 && Date.now() - seen[0][0] > 4000) seen.shift();
|
||||
const dt = (seen[seen.length - 1][0] - seen[0][0]) / 1000, rate = dt > 0.5 ? (seen[seen.length - 1][1] - seen[0][1]) / dt : 0;
|
||||
$('mv-ptext').textContent = fmtSize(p.done) + ' из ' + fmtSize(p.total) + ' · файлов: ' + p.files + (rate ? ' · ' + fmtSize(Math.round(rate)) + '/с' : '');
|
||||
} else if (p.note) {
|
||||
$('mv-ptext').textContent = p.note === 'подсчёт' ? 'подсчёт…' : p.note + '…';
|
||||
}
|
||||
if (j.done) res = j.result;
|
||||
}
|
||||
if (text) $('mv-ptext').textContent = text;
|
||||
});
|
||||
M.job = null;
|
||||
if (!res.ok) { busy(false); toast(res.error || 'ошибка', true); return; }
|
||||
$('mv-bar').style.width = '100%';
|
||||
@@ -927,6 +939,6 @@
|
||||
del: (pane, e) => use(pane, del)(e),
|
||||
uploads(pane) { upPane = pane; openModal($('updlg')); renderUploadList(); },
|
||||
uploadsChanged(pane) { if (pane === upPane) renderUploadList(); },
|
||||
sendFile, toast, openModal, join, parent, fmtSize, fmtTime, esc, b64ToBytes
|
||||
sendFile, followJob, toast, openModal, join, parent, fmtSize, fmtTime, esc, b64ToBytes
|
||||
};
|
||||
})();
|
||||
@@ -664,12 +664,16 @@
|
||||
if (location.pathname !== '/terminal' && window.App) App.go('/terminal');
|
||||
return t;
|
||||
}
|
||||
const in_ = (el, x, y) => { const r = el.getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; };
|
||||
window.Workspace = {
|
||||
paneTypes, desk, touch, open, activate,
|
||||
hasLiveSessions: () => tabs.some(t => t.panes.some(p => p.live())),
|
||||
panes: () => tabs.flatMap(t => t.panes),
|
||||
activePane: () => activePane,
|
||||
renderPane: p => { const t = tabOf(p); if (t) renderTab(t); if (p === activePane) showStatus(); },
|
||||
setActivePane, tabOf, renderTab, showStatus, closePane, startDrag
|
||||
setActivePane, tabOf, renderTab, showStatus, closePane, startDrag,
|
||||
// paneAt, tabAt: the pane of the shown tab, the tab title at a point.
|
||||
paneAt: (x, y) => active && active.panes.find(p => in_(p.el, x, y)),
|
||||
tabAt: (x, y) => document.body.classList.contains('multi') ? tabs.find(t => in_(t.el, x, y)) : null
|
||||
};
|
||||
})();
|
||||
@@ -33,3 +33,9 @@ test('fmtSize', () => {
|
||||
assert.strictEqual(U.fmtSize(10), '10 B');
|
||||
assert.strictEqual(U.fmtSize(2048), '2.0 K');
|
||||
});
|
||||
|
||||
test('dropAction: other SSH user on the same host is another place', () => {
|
||||
const a = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'};
|
||||
const b = {kind: 'ssh', id: 'm1|bob@h:22', label: 'bob@h'};
|
||||
assert.strictEqual(U.dropAction({source: a, paths: ['/x'], dir: '/'}, {kind: 'term', source: b}).kind, 'deny');
|
||||
});
|
||||
@@ -202,6 +202,7 @@
|
||||
<script src="{{asset "dbedit.js"}}"></script>
|
||||
<script src="{{asset "fileviews.js"}}"></script>
|
||||
<script src="{{asset "filepane.js"}}"></script>
|
||||
<script src="{{asset "dnd.js"}}"></script>
|
||||
<script src="{{asset "pages.js"}}"></script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
|
||||
Reference in new issue
Block a user