feat: move or copy dialog with folder suggestions and a rule for what is already there
This commit is contained in:
1 parent
89d5bfd3ab
commit
e983fc179a
5 files changed
+337
-6
No files matched your search
@@ -80,6 +80,10 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) {
|
|||||||
var a proto.FSChmodArgs
|
var a proto.FSChmodArgs
|
||||||
_ = json.Unmarshal(env.Args, &a)
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
return fsChmod(a)
|
return fsChmod(a)
|
||||||
|
case proto.TaskFSCopy:
|
||||||
|
var a proto.FSCopyArgs
|
||||||
|
_ = json.Unmarshal(env.Args, &a)
|
||||||
|
return fsCopy(a)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fsCopy copies or moves Src to Dst, following Conflict when Dst exists.
|
||||||
|
func fsCopy(a proto.FSCopyArgs) (any, error) {
|
||||||
|
src, dst := filepath.Clean(a.Src), filepath.Clean(a.Dst)
|
||||||
|
if a.Src == "" || a.Dst == "" {
|
||||||
|
return nil, errors.New("не указан путь")
|
||||||
|
}
|
||||||
|
if src == dst {
|
||||||
|
return nil, errors.New("откуда и куда - одно и то же")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(dst, src+string(filepath.Separator)) {
|
||||||
|
return nil, errors.New("нельзя положить папку внутрь неё самой")
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(src); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Dir(dst)); err != nil {
|
||||||
|
return nil, fmt.Errorf("нет папки %s", filepath.Dir(dst))
|
||||||
|
}
|
||||||
|
c := &copier{move: a.Move, conflict: a.Conflict}
|
||||||
|
if err := c.place(src, dst); err != nil {
|
||||||
|
return map[string]int{"done": c.done, "skipped": c.skipped}, err
|
||||||
|
}
|
||||||
|
return map[string]int{"done": c.done, "skipped": c.skipped}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type copier struct {
|
||||||
|
move bool
|
||||||
|
conflict string
|
||||||
|
done, skipped int
|
||||||
|
}
|
||||||
|
|
||||||
|
// place puts src at dst. Two folders merge when the rule allows it; anything
|
||||||
|
// else that is in the way stops, is replaced or is kept, by the rule.
|
||||||
|
func (c *copier) place(src, dst string) error {
|
||||||
|
si, err := os.Lstat(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
di, err := os.Lstat(dst)
|
||||||
|
if err == nil {
|
||||||
|
if c.conflict != "replace" && c.conflict != "skip" {
|
||||||
|
return fmt.Errorf("%s уже существует", dst)
|
||||||
|
}
|
||||||
|
if si.IsDir() && di.IsDir() {
|
||||||
|
entries, err := os.ReadDir(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if err := c.place(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.move {
|
||||||
|
// Whatever was skipped stays where it was.
|
||||||
|
_ = os.Remove(src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.conflict == "skip" {
|
||||||
|
c.skipped++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.move {
|
||||||
|
err := os.Rename(src, dst)
|
||||||
|
if err == nil {
|
||||||
|
c.done++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Another file system: copy, then take the original away.
|
||||||
|
if !errors.Is(err, syscall.EXDEV) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.copyTree(src, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(src)
|
||||||
|
}
|
||||||
|
return c.copyTree(src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTree copies a file, a symlink or a whole folder, keeping modes and
|
||||||
|
// times.
|
||||||
|
func (c *copier) copyTree(src, dst string) error {
|
||||||
|
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(src, p)
|
||||||
|
to := filepath.Join(dst, rel)
|
||||||
|
info, err := os.Lstat(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case info.Mode()&fs.ModeSymlink != 0:
|
||||||
|
link, err := os.Readlink(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Symlink(link, to); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case info.IsDir():
|
||||||
|
if err := os.Mkdir(to, info.Mode().Perm()); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case info.Mode().IsRegular():
|
||||||
|
if err := copyFile(p, to, info); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil // sockets, devices: not copied
|
||||||
|
}
|
||||||
|
c.done++
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
_ = os.Lchown(to, int(st.Uid), int(st.Gid))
|
||||||
|
}
|
||||||
|
if info.Mode()&fs.ModeSymlink == 0 {
|
||||||
|
_ = os.Chtimes(to, info.ModTime(), info.ModTime())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string, info fs.FileInfo) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(dst)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chmod(dst, info.Mode()&(fs.ModePerm|fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky))
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ const (
|
|||||||
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
|
||||||
TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner
|
TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner
|
||||||
TaskFSChmod = "fs.chmod" // change mode and owner, of a folder's contents too
|
TaskFSChmod = "fs.chmod" // change mode and owner, of a folder's contents too
|
||||||
|
TaskFSCopy = "fs.copy" // copy or move, with a rule for what is already there
|
||||||
TaskFSPut = "fs.put" // write a file the browser sends in pieces
|
TaskFSPut = "fs.put" // write a file the browser sends in pieces
|
||||||
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
TaskFSSend = "fs.send" // send a file to the browser in pieces
|
||||||
|
|
||||||
@@ -178,6 +179,17 @@ type FSChmodArgs struct {
|
|||||||
Only string `json:"only,omitempty"`
|
Only string `json:"only,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FSCopyArgs are the arguments of TaskFSCopy: Src goes to Dst (the full new
|
||||||
|
// path), copied or, with Move, moved. Conflict says what to do when Dst is
|
||||||
|
// already there: "error" (the default) stops, "replace" puts Src in its place,
|
||||||
|
// "skip" keeps it. Folders on both sides are merged under replace and skip.
|
||||||
|
type FSCopyArgs struct {
|
||||||
|
Src string `json:"src"`
|
||||||
|
Dst string `json:"dst"`
|
||||||
|
Move bool `json:"move,omitempty"`
|
||||||
|
Conflict string `json:"conflict,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// FSEntry is one directory entry.
|
// FSEntry is one directory entry.
|
||||||
type FSEntry struct {
|
type FSEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ var fsOps = map[string]string{
|
|||||||
"move": proto.TaskFSMove,
|
"move": proto.TaskFSMove,
|
||||||
"stat": proto.TaskFSStat,
|
"stat": proto.TaskFSStat,
|
||||||
"chmod": proto.TaskFSChmod,
|
"chmod": proto.TaskFSChmod,
|
||||||
|
"copy": proto.TaskFSCopy,
|
||||||
}
|
}
|
||||||
|
|
||||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||||
|
|||||||
@@ -92,6 +92,28 @@
|
|||||||
/* Square icon buttons, the height of the buttons beside them. */
|
/* Square icon buttons, the height of the buttons beside them. */
|
||||||
.btn.sq { width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
|
.btn.sq { width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
|
||||||
.fm-bar .btn.sq { width:34px; }
|
.fm-bar .btn.sq { width:34px; }
|
||||||
|
/* Move or copy: the action, where to with suggestions, the name, and what
|
||||||
|
to do with what is already there. */
|
||||||
|
#mvdlg { max-width:560px; }
|
||||||
|
#mvdlg .dlg-b { padding:14px 18px 16px; }
|
||||||
|
.mv label { margin:12px 0 4px; }
|
||||||
|
.mv .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
|
||||||
|
.mv .pp-act { display:flex; justify-content:flex-end; margin-top:16px; }
|
||||||
|
.mv input[type=text], .mv select { width:100%; height:36px; padding:6px 10px; }
|
||||||
|
.mv select { padding-right:36px; }
|
||||||
|
.seg { display:inline-flex; margin-top:12px; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||||
|
.seg button { background:transparent; color:var(--muted); border:0; border-radius:0; padding:7px 16px; font-size:14px; }
|
||||||
|
.seg button + button { border-left:1px solid var(--border); }
|
||||||
|
.seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 -2px 0 var(--accent); }
|
||||||
|
.seg button:hover { background:#151a21; color:var(--text); }
|
||||||
|
.mv-dirbox { position:relative; }
|
||||||
|
.mv-dirbox input { font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||||
|
.mv-sug { position:absolute; left:0; right:0; top:calc(100% + 4px); z-index:5; max-height:220px; overflow:auto;
|
||||||
|
margin:0; padding:4px; list-style:none; background:var(--panel); border:1px solid var(--border); border-radius:8px;
|
||||||
|
box-shadow:0 8px 24px rgba(0,0,0,.45); font-family:ui-monospace,Menlo,monospace; font-size:13px; }
|
||||||
|
.mv-sug li { padding:5px 8px; border-radius:5px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.mv-sug li.sel, .mv-sug li:hover { background:#12202f; color:var(--text); }
|
||||||
|
.mv-sug li .muted { color:var(--muted); }
|
||||||
/* Properties: owner, group and mode, like FileZilla's. */
|
/* Properties: owner, group and mode, like FileZilla's. */
|
||||||
#propdlg { max-width:520px; }
|
#propdlg { max-width:520px; }
|
||||||
#propdlg .dlg-b { padding:14px 18px 16px; }
|
#propdlg .dlg-b { padding:14px 18px 16px; }
|
||||||
@@ -312,6 +334,34 @@
|
|||||||
<div class="dlg-b" id="updlg-list"></div>
|
<div class="dlg-b" id="updlg-list"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="mvdlg" tabindex="-1">
|
||||||
|
<div class="dlg-h">
|
||||||
|
<strong style="flex:1">Переместить или копировать</strong>
|
||||||
|
<button class="btn ghost small icon close x" id="mvdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="dlg-b mv">
|
||||||
|
<div class="pp-path" id="mv-src"></div>
|
||||||
|
<div class="seg" id="mv-op" role="radiogroup">
|
||||||
|
<button type="button" data-op="move" role="radio">Переместить</button>
|
||||||
|
<button type="button" data-op="copy" role="radio">Копировать</button>
|
||||||
|
</div>
|
||||||
|
<label for="mv-dir">Куда (папка)</label>
|
||||||
|
<div class="mv-dirbox">
|
||||||
|
<input type="text" id="mv-dir" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||||
|
<ul class="mv-sug" id="mv-sug" hidden></ul>
|
||||||
|
</div>
|
||||||
|
<label for="mv-name">Имя</label>
|
||||||
|
<input type="text" id="mv-name" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||||
|
<label for="mv-conflict">Если там уже есть такое</label>
|
||||||
|
<select id="mv-conflict">
|
||||||
|
<option value="error">ничего не трогать и сообщить</option>
|
||||||
|
<option value="replace">заменить (папки слить, файлы заменить)</option>
|
||||||
|
<option value="skip">оставить существующее (папки слить)</option>
|
||||||
|
</select>
|
||||||
|
<div class="pp-act"><button type="button" id="mv-go">Переместить</button></div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="propdlg" tabindex="-1">
|
<dialog id="propdlg" tabindex="-1">
|
||||||
<div class="dlg-h">
|
<div class="dlg-h">
|
||||||
<strong style="flex:1">Свойства</strong>
|
<strong style="flex:1">Свойства</strong>
|
||||||
@@ -458,7 +508,7 @@
|
|||||||
'<td><div class="act">' +
|
'<td><div class="act">' +
|
||||||
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
|
(isZip?'<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">'+ICON_UNZIP+'</button>':'') +
|
||||||
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
||||||
'<button class="btn ghost small icon" data-act="ren" title="Переместить / переименовать">'+ICON_MV+'</button>' +
|
'<button class="btn ghost small icon" data-act="ren" title="Переместить или копировать">'+ICON_MV+'</button>' +
|
||||||
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
|
'<button class="btn danger small icon" data-act="del" title="Удалить">'+ICON_DEL+'</button>' +
|
||||||
'</div></td>';
|
'</div></td>';
|
||||||
const nm = tr.querySelector('.nm');
|
const nm = tr.querySelector('.nm');
|
||||||
@@ -1087,12 +1137,110 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function move(e) {
|
// ---------- Move or copy ----------
|
||||||
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
|
const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0 };
|
||||||
if (!dst) return;
|
const dirCache = new Map(); // folder -> {at, names} of its subfolders
|
||||||
const r = await api('move', {src: join(cwd, e.name), dst});
|
// Lists a folder for the suggestions, quietly: no spinner, kept a little.
|
||||||
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
|
async function subdirs(path) {
|
||||||
|
const c = dirCache.get(path);
|
||||||
|
if (c && Date.now() - c.at < 30000) return c.names;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/list', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({path})
|
||||||
|
}).then(r => r.json());
|
||||||
|
const names = r.ok ? (r.data.entries || []).filter(e => e.is_dir).map(e => e.name).sort(byNameStr) : [];
|
||||||
|
dirCache.set(path, {at: Date.now(), names});
|
||||||
|
return names;
|
||||||
|
} catch (e) { return []; }
|
||||||
}
|
}
|
||||||
|
const byNameStr = (a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'});
|
||||||
|
function setOp(op) {
|
||||||
|
M.op = op;
|
||||||
|
document.querySelectorAll('#mv-op button').forEach(b => { b.classList.toggle('on', b.dataset.op === op); b.setAttribute('aria-checked', b.dataset.op === op); });
|
||||||
|
$('mv-go').textContent = op === 'move' ? 'Переместить' : 'Копировать';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#mv-op button').forEach(b => b.onclick = () => setOp(b.dataset.op));
|
||||||
|
// Suggestions: the subfolders of what is typed, matching its last part.
|
||||||
|
async function suggest() {
|
||||||
|
const v = $('mv-dir').value, seq = ++M.seq;
|
||||||
|
const i = v.lastIndexOf('/');
|
||||||
|
const parent = i <= 0 ? '/' : v.slice(0, i), pre = v.slice(i + 1).toLowerCase();
|
||||||
|
const names = await subdirs(parent);
|
||||||
|
if (seq !== M.seq) return;
|
||||||
|
M.sug = names.filter(n => n.toLowerCase().startsWith(pre)).slice(0, 60)
|
||||||
|
.map(n => (parent === '/' ? '' : parent) + '/' + n + '/');
|
||||||
|
M.sel = -1;
|
||||||
|
showSug();
|
||||||
|
}
|
||||||
|
function showSug() {
|
||||||
|
const ul = $('mv-sug');
|
||||||
|
ul.innerHTML = '';
|
||||||
|
M.sug.forEach((full, k) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const cut = full.slice(0, -1).lastIndexOf('/') + 1;
|
||||||
|
li.innerHTML = '<span class="muted"></span><span></span>';
|
||||||
|
li.firstChild.textContent = full.slice(0, cut);
|
||||||
|
li.lastChild.textContent = full.slice(cut);
|
||||||
|
if (k === M.sel) li.className = 'sel';
|
||||||
|
li.onmousedown = ev => { ev.preventDefault(); pick(k); };
|
||||||
|
ul.appendChild(li);
|
||||||
|
});
|
||||||
|
ul.hidden = !M.sug.length || document.activeElement !== $('mv-dir');
|
||||||
|
const sel = ul.children[M.sel];
|
||||||
|
if (sel) sel.scrollIntoView({block: 'nearest'});
|
||||||
|
}
|
||||||
|
function pick(k) {
|
||||||
|
$('mv-dir').value = M.sug[k];
|
||||||
|
suggest();
|
||||||
|
}
|
||||||
|
let sugTimer = null;
|
||||||
|
$('mv-dir').addEventListener('input', () => { clearTimeout(sugTimer); sugTimer = setTimeout(suggest, 120); });
|
||||||
|
$('mv-dir').addEventListener('focus', suggest);
|
||||||
|
$('mv-dir').addEventListener('blur', () => setTimeout(() => { $('mv-sug').hidden = true; }, 100));
|
||||||
|
$('mv-dir').addEventListener('keydown', ev => {
|
||||||
|
const open = !$('mv-sug').hidden && M.sug.length;
|
||||||
|
if (ev.key === 'ArrowDown' && open) { ev.preventDefault(); M.sel = (M.sel + 1) % M.sug.length; showSug(); }
|
||||||
|
else if (ev.key === 'ArrowUp' && open) { ev.preventDefault(); M.sel = (M.sel - 1 + M.sug.length) % M.sug.length; showSug(); }
|
||||||
|
else if ((ev.key === 'Tab' || ev.key === 'Enter') && open && (M.sel >= 0 || ev.key === 'Tab')) { ev.preventDefault(); pick(Math.max(0, M.sel)); }
|
||||||
|
else if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); }
|
||||||
|
else if (ev.key === 'Escape' && open) { ev.preventDefault(); ev.stopPropagation(); $('mv-sug').hidden = true; }
|
||||||
|
});
|
||||||
|
$('mv-name').addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); } });
|
||||||
|
|
||||||
|
function move(e) {
|
||||||
|
M.src = join(cwd, e.name);
|
||||||
|
$('mv-src').textContent = M.src + (e.is_dir ? '/' : '');
|
||||||
|
setOp('move');
|
||||||
|
$('mv-dir').value = cwd === '/' ? '/' : cwd + '/';
|
||||||
|
$('mv-name').value = e.name;
|
||||||
|
$('mv-conflict').value = 'error';
|
||||||
|
dirCache.clear();
|
||||||
|
openModal($('mvdlg'));
|
||||||
|
$('mv-dir').focus();
|
||||||
|
const end = $('mv-dir').value.length;
|
||||||
|
$('mv-dir').setSelectionRange(end, end);
|
||||||
|
}
|
||||||
|
$('mv-go').onclick = async () => {
|
||||||
|
const dir = $('mv-dir').value.trim().replace(/\/+$/, '') || '/';
|
||||||
|
const name = $('mv-name').value.trim();
|
||||||
|
if (!name || name.includes('/')) { toast('имя - без «/»', true); return; }
|
||||||
|
const dst = (dir === '/' ? '' : dir) + '/' + name;
|
||||||
|
const conflict = $('mv-conflict').value, move = M.op === 'move';
|
||||||
|
const btn = $('mv-go');
|
||||||
|
btn.disabled = true;
|
||||||
|
let r = await api('copy', {src: M.src, dst, move, conflict});
|
||||||
|
// A client that predates copying still moves, when nothing is in the way.
|
||||||
|
if (!r.ok && /неизвестная операция/.test(r.error || '')) {
|
||||||
|
r = move && conflict === 'error' ? await api('move', {src: M.src, dst}) : {ok: false, error: 'копирование и замена: обновите клиента'};
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
if (!r.ok) { toast(r.error || 'ошибка', true); return; }
|
||||||
|
const skipped = r.data && r.data.skipped;
|
||||||
|
toast((move ? 'перемещено' : 'скопировано') + (skipped ? ', оставлено как было: ' + skipped : ''));
|
||||||
|
$('mvdlg').close();
|
||||||
|
load(cwd);
|
||||||
|
};
|
||||||
|
$('mvdlg-close').onclick = () => $('mvdlg').close();
|
||||||
|
|
||||||
async function unzip(e) {
|
async function unzip(e) {
|
||||||
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
|
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});
|
||||||
|
|||||||
Reference in new issue
Block a user