222 lines
10 KiB
JavaScript
222 lines
10 KiB
JavaScript
// 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;
|
|
// Keep the file pane's pointer drag as the only drag source. Otherwise
|
|
// the browser can start a native text-selection drag beside our ghost.
|
|
ev.preventDefault();
|
|
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)';
|
|
// What the browser began to select before the drag was told apart goes.
|
|
const sel = getSelection();
|
|
if (sel && !sel.isCollapsed) sel.removeAllRanges();
|
|
d.target = null;
|
|
box.hidden = true;
|
|
d.ghost.dataset.hint = '';
|
|
if (window.AIChat) window.AIChat.setDragging(false);
|
|
// 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; }
|
|
if (window.AIChat && window.AIChat.isDropTargetAt(ev.clientX, ev.clientY)) {
|
|
window.AIChat.setDragging(true);
|
|
d.target = {kind: 'ai'};
|
|
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');
|
|
if (window.AIChat) window.AIChat.setDragging(false);
|
|
justDropped = true;
|
|
setTimeout(() => { justDropped = false; }, 0);
|
|
if (cancel || !x.target) return;
|
|
if (x.target.kind === 'ai') { window.AIChat.dropFiles(x.pane, x.names); 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);
|
|
}
|
|
// While FileDnD owns the pointer, never let the browser start a second,
|
|
// native drag (for example from the SVG icon or the filename under it).
|
|
document.addEventListener('dragstart', ev => {
|
|
if (!d) return;
|
|
ev.preventDefault();
|
|
ev.stopImmediatePropagation();
|
|
}, true);
|
|
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 = 'Стоп';
|
|
}
|
|
// Between places, all of them go as one stream: the first, and the rest
|
|
// as "more" (the server sends one by one to older agents).
|
|
const batch = here ? item.paths.map(p => [p]) : [item.paths];
|
|
for (const group of batch) {
|
|
const p = group[0];
|
|
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, more: group.slice(1)};
|
|
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 = '';
|
|
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 += group.length;
|
|
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); }};
|
|
})();
|