feat: move or copy dialog with folder suggestions and a rule for what is already there

This commit is contained in:
redtea committed 2026-10-14 20:25:59 +03:00
1 parent 89d5bfd3ab
commit e983fc179a
5 files changed
+337 -6

No files matched your search

+4
View File
@@ -80,6 +80,10 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) {
var a proto.FSChmodArgs
_ = json.Unmarshal(env.Args, &a)
return fsChmod(a)
case proto.TaskFSCopy:
var a proto.FSCopyArgs
_ = json.Unmarshal(env.Args, &a)
return fsCopy(a)
default:
return nil, fmt.Errorf("неизвестная операция %q", env.Type)
}
+166
View File
@@ -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))
}
+12
View File
@@ -87,6 +87,7 @@ const (
TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download
TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner
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
TaskFSSend = "fs.send" // send a file to the browser in pieces
@@ -178,6 +179,17 @@ type FSChmodArgs struct {
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.
type FSEntry struct {
Name string `json:"name"`
+1
View File
@@ -21,6 +21,7 @@ var fsOps = map[string]string{
"move": proto.TaskFSMove,
"stat": proto.TaskFSStat,
"chmod": proto.TaskFSChmod,
"copy": proto.TaskFSCopy,
}
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
+154 -6
View File
@@ -92,6 +92,28 @@
/* 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; }
.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. */
#propdlg { max-width:520px; }
#propdlg .dlg-b { padding:14px 18px 16px; }
@@ -312,6 +334,34 @@
<div class="dlg-b" id="updlg-list"></div>
</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">
<div class="dlg-h">
<strong style="flex:1">Свойства</strong>
@@ -458,7 +508,7 @@
'<td><div class="act">' +
(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>') +
'<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>' +
'</div></td>';
const nm = tr.querySelector('.nm');
@@ -1087,12 +1137,110 @@
}
}
async function move(e) {
const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name));
if (!dst) return;
const r = await api('move', {src: join(cwd, e.name), dst});
r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true);
// ---------- Move or copy ----------
const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0 };
const dirCache = new Map(); // folder -> {at, names} of its subfolders
// Lists a folder for the suggestions, quietly: no spinner, kept a little.
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) {
const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});