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

+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});