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
@@ -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});
|
||||
|
||||
Reference in new issue
Block a user