Files
OmniMTP/src/ui/webroot/app.js
niklascfw 413cc8fbde Add Finder-style New Folder with inline rename on the Switch.
Create unique folders without a crashing prompt, then focus the name for editing; also fix folder association type and UTF-8 MTP string encoding.
2026-08-07 00:24:47 +02:00

765 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ── Bridge ────────────────────────────────────────────────────────────────────
let _reqId = 0;
const _pending = {};
function call(action, params = {}) {
return new Promise(resolve => {
const id = ++_reqId;
_pending[id] = resolve;
try {
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
} catch(e) {
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
}
});
}
window.bridgeResponse = function(id, data) {
if (_pending[id]) { _pending[id](data); delete _pending[id]; }
};
// ── i18n ──────────────────────────────────────────────────────────────────────
const I18N = {
lang: 'en',
strings: {},
fallback: {},
};
function t(key, vars = {}) {
let s = I18N.strings[key] ?? I18N.fallback[key] ?? key;
for (const [k, v] of Object.entries(vars))
s = s.replaceAll(`{${k}}`, String(v));
return s;
}
function tPlural(oneKey, manyKey, n, extra = {}) {
return t(n === 1 ? oneKey : manyKey, { n, ...extra });
}
function localeCandidates(preferred) {
const out = [];
const add = (c) => { if (c && !out.includes(c)) out.push(c); };
for (const raw of preferred || []) {
const tag = String(raw || '').replace(/_/g, '-');
if (!tag) continue;
add(tag);
const parts = tag.split('-');
if (parts[0] === 'zh') {
const script = (parts[1] || '').toLowerCase();
if (script.startsWith('hant') || script === 'tw' || script === 'hk' || script === 'mo')
add('zh-Hant');
else
add('zh-Hans');
add('zh');
} else {
add(parts[0]);
}
}
add('en');
return out;
}
async function fetchLang(code) {
const pack = await call('get_lang_pack', { code });
return (pack && typeof pack === 'object') ? pack : null;
}
async function initI18n() {
// Native injects packs at document start (fetch() is blocked on file://).
if (window.__OMNI_I18N__ && typeof window.__OMNI_I18N__ === 'object') {
const boot = window.__OMNI_I18N__;
I18N.lang = boot.lang || 'en';
I18N.fallback = boot.fallback || {};
I18N.strings = boot.strings || { ...I18N.fallback };
} else {
I18N.fallback = (await fetchLang('en')) || {};
I18N.strings = { ...I18N.fallback };
let preferred = await call('get_preferred_languages');
if (!Array.isArray(preferred)) preferred = ['en'];
for (const code of localeCandidates(preferred)) {
if (code === 'en') {
I18N.lang = 'en';
break;
}
const pack = await fetchLang(code);
if (pack) {
I18N.lang = code;
I18N.strings = { ...I18N.fallback, ...pack };
break;
}
}
}
document.documentElement.lang = I18N.lang;
applyStaticI18n();
}
function applyStaticI18n() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle);
});
document.title = t('app.title');
}
window.omniApplyI18n = function(payload) {
if (!payload || typeof payload !== 'object') return;
window.__OMNI_I18N__ = payload;
I18N.lang = payload.lang || 'en';
I18N.fallback = payload.fallback || {};
I18N.strings = payload.strings || { ...I18N.fallback };
document.documentElement.lang = I18N.lang;
applyStaticI18n();
_remoteFingerprint = '';
if (typeof render === 'function') render();
};
async function confirmDialog(message) {
const result = await call('confirm', {
message,
ok: t('confirm.delete_btn'),
cancel: t('confirm.cancel_btn'),
});
return result === true || result === 'true';
}
let _pendingRenameHandle = null;
let _inlineRenameActive = false;
async function createFolderInline() {
if (!S.connected || _inlineRenameActive) return;
const result = await call('create_folder', {
defaultName: t('prompt.new_folder_default'),
});
if (!result || result.handle == null) return;
_pendingRenameHandle = String(result.handle);
_remoteFingerprint = '';
renderRemoteFiles();
}
function startInlineRename(handleKey) {
const key = String(handleKey);
const row = document.querySelector(`#remote-files .frow[data-key="${CSS.escape(key)}"]`);
if (!row) {
_pendingRenameHandle = key;
return;
}
if (row.querySelector('.fname-edit')) return;
_pendingRenameHandle = null;
const textEl = row.querySelector('.fname-text');
if (!textEl) return;
const oldName = textEl.textContent;
const className = textEl.className;
const input = document.createElement('input');
input.type = 'text';
input.className = 'fname-edit';
input.value = oldName;
input.setAttribute('spellcheck', 'false');
textEl.replaceWith(input);
_inlineRenameActive = true;
input.focus();
input.select();
let finished = false;
const finish = async (commit) => {
if (finished) return;
finished = true;
_inlineRenameActive = false;
const newName = input.value.trim();
if (commit && newName && newName !== oldName) {
await call('rename_remote', { handle: Number(key), name: newName });
}
_remoteFingerprint = '';
renderRemoteFiles();
};
input.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
});
input.addEventListener('blur', () => finish(true));
input.addEventListener('mousedown', e => e.stopPropagation());
input.addEventListener('click', e => e.stopPropagation());
input.addEventListener('dblclick', e => e.stopPropagation());
}
// ── State ─────────────────────────────────────────────────────────────────────
const S = {
connected: false, deviceName: '',
storages: [], activeStorageId: 0,
remoteNavStack: [],
remoteFiles: [], remoteRefreshing: false,
transfers: [],
statusKey: '', statusArgs: [], statusTimer: 0,
selRemote: new Set(),
lastRemoteIdx: -1,
sortKey: 'name', // name | size | date
sortAsc: true,
};
let _remoteFingerprint = '';
function fingerprintFiles(files) {
return files.map(f => f.path || f.handle || f.name).join('\0')
+ '\0' + I18N.lang + '\0' + S.sortKey + '\0' + (S.sortAsc ? 'a' : 'd');
}
function sortedRemoteFiles() {
const files = (S.remoteFiles || []).slice();
const dir = S.sortAsc ? 1 : -1;
const key = S.sortKey;
files.sort((a, b) => {
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
let cmp = 0;
if (key === 'size') {
cmp = (a.size || 0) - (b.size || 0);
} else if (key === 'date') {
cmp = String(a.date || '').localeCompare(String(b.date || ''));
} else {
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
sensitivity: 'base', numeric: true,
});
}
if (cmp === 0) {
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
sensitivity: 'base', numeric: true,
});
}
return cmp * dir;
});
return files;
}
function setSortColumn(key) {
if (S.sortKey === key) S.sortAsc = !S.sortAsc;
else {
S.sortKey = key;
S.sortAsc = key !== 'size'; // size defaults to largest-first feels odd; use ascending
}
_remoteFingerprint = '';
renderRemoteFiles();
}
async function poll() {
try {
const data = await call('get_state');
if (data) {
const sr = S.selRemote;
const lri = S.lastRemoteIdx;
Object.assign(S, data);
S.selRemote = sr;
S.lastRemoteIdx = lri;
render();
}
} catch(e) {}
setTimeout(poll, 400);
}
function render() {
renderDeviceBar();
renderRemoteToolbar();
renderRemoteFiles();
renderTransfers();
renderStatusBar();
updateDropZones();
}
function updateDropZones() {
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
if (!rem) return;
call('update_drop_zones', {
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
});
}
function esc(s) {
if (s == null) return '';
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function fileColor(f) {
if (f.isDir) return 'color-dir';
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (['nsp','xci','nsz','xcz'].includes(ext)) return 'color-game';
if (ext === 'nro') return 'color-hb';
return 'color-file';
}
function fileTableHeader() {
const ind = (key) => {
if (S.sortKey !== key) return '<span class="sort-ind"></span>';
return `<span class="sort-ind active">${S.sortAsc ? '▲' : '▼'}</span>`;
};
return `<thead><tr>
<th class="sortable" data-sort="name">${esc(t('col.name'))}${ind('name')}</th>
<th class="col-size sortable" data-sort="size">${esc(t('col.size'))}${ind('size')}</th>
<th class="col-date sortable" data-sort="date">${esc(t('col.modified'))}${ind('date')}</th>
</tr></thead>`;
}
function buildFileRows(files, selSet, idKey) {
return files.map((f, i) => {
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
const sel = selSet.has(key) ? ' selected' : '';
const cc = fileColor(f);
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
<td class="col-name"><div class="fname-cell">
<span class="dot ${cc}"></span>
<span class="fname-text ${cc}">${esc(f.name)}</span>
</div></td>
<td class="col-size">${f.isDir ? '' : esc(f.sizeStr)}</td>
<td class="col-date">${esc(f.date)}</td>
</tr>`;
}).join('');
}
function applySelectionToDOM(container, selSet) {
container.querySelectorAll('.frow').forEach(r => {
r.classList.toggle('selected', selSet.has(r.dataset.key));
});
}
function selectionKeys(files, idKey) {
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name)));
}
function attachMarqueeSelect(el, selSet) {
let active = false, startX = 0, startY = 0, box = null, additive = false, moved = false;
const finish = () => {
if (!active) return;
const clearOnClick = !moved && !additive;
active = false;
if (box) { box.remove(); box = null; }
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', finish);
if (clearOnClick) {
selSet.clear();
applySelectionToDOM(el, selSet);
S.lastRemoteIdx = -1;
}
};
const onMove = e => {
if (!active) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
if (!moved && Math.hypot(dx, dy) < 4) return;
moved = true;
window.omniClearSelection();
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
Object.assign(box.style, {
left: x1 + 'px', top: y1 + 'px',
width: (x2 - x1) + 'px', height: (y2 - y1) + 'px',
});
if (!additive) selSet.clear();
el.querySelectorAll('.frow').forEach(row => {
const r = row.getBoundingClientRect();
const hit = r.right >= x1 && r.left <= x2 && r.bottom >= y1 && r.top <= y2;
if (hit) selSet.add(row.dataset.key);
});
applySelectionToDOM(el, selSet);
};
el.addEventListener('mousedown', e => {
if (e.button !== 0 || e.target.closest('.frow') || e.target.closest('thead')) return;
e.preventDefault();
active = true;
moved = false;
additive = e.metaKey || e.shiftKey;
startX = e.clientX;
startY = e.clientY;
box = document.createElement('div');
box.className = 'marquee-box';
document.body.appendChild(box);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', finish);
});
}
function renderRemoteFiles() {
const el = document.getElementById('remote-files');
const empty = document.getElementById('remote-empty-state');
// Don't clobber an in-progress inline rename.
if (_inlineRenameActive) return;
if (!S.connected) {
if (empty) empty.style.display = '';
el.querySelectorAll('table,.loading,.empty-state:not(#remote-empty-state)')
.forEach(n => n.remove());
_remoteFingerprint = '';
return;
}
if (empty) empty.style.display = 'none';
if (S.remoteRefreshing) {
el.innerHTML = `<div class="loading">${esc(t('loading'))}</div>`;
_remoteFingerprint = '__loading__' + I18N.lang;
return;
}
const fp = fingerprintFiles(S.remoteFiles);
if (fp !== _remoteFingerprint) {
_remoteFingerprint = fp;
if (!S.remoteFiles.length) {
el.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
</svg>
<div class="empty-title">${esc(t('empty.folder'))}</div></div>`;
} else {
const files = sortedRemoteFiles();
el.innerHTML = `<table class="ftable">${fileTableHeader()}
<tbody>${buildFileRows(files, S.selRemote, 'handle')}</tbody></table>`;
}
attachRemoteEvents(el);
} else {
applySelectionToDOM(el, S.selRemote);
}
if (_pendingRenameHandle) {
const handle = _pendingRenameHandle;
requestAnimationFrame(() => startInlineRename(handle));
}
}
function attachRemoteEvents(el) {
const files = sortedRemoteFiles();
const keys = selectionKeys(files, 'handle');
el.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
setSortColumn(th.dataset.sort);
});
});
el.querySelectorAll('.frow').forEach((row, i) => {
const f = files[i];
const key = keys[i];
row.addEventListener('click', e => {
handleRowClick(e, i, key, S.selRemote, keys, 'lastRemoteIdx');
applySelectionToDOM(el, S.selRemote);
});
row.addEventListener('dblclick', () => {
if (f.isDir) call('navigate_remote', {handle: f.handle, storageId: f.storageId, name: f.name});
});
row.addEventListener('contextmenu', e => { e.preventDefault(); showRemoteCtxMenu(f, e); });
});
attachMarqueeSelect(el, S.selRemote);
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
el.addEventListener('drop', e => {
e.preventDefault();
el.classList.remove('drag-over');
for (const file of (e.dataTransfer.files || [])) {
const p = file.path || '';
if (p) call('start_upload', {srcPath: p});
}
});
}
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
if (e.shiftKey && S[lastIdxProp] >= 0) {
const lo = Math.min(idx, S[lastIdxProp]);
const hi = Math.max(idx, S[lastIdxProp]);
if (!e.metaKey) selSet.clear();
allKeys.slice(lo, hi + 1).forEach(k => selSet.add(k));
} else if (e.metaKey) {
if (selSet.has(key)) selSet.delete(key); else selSet.add(key);
S[lastIdxProp] = idx;
} else {
selSet.clear();
selSet.add(key);
S[lastIdxProp] = idx;
}
}
function renderRemoteToolbar() {
const sel = document.getElementById('storage-sel');
if (S.storages && S.storages.length) {
const prev = sel.value;
sel.innerHTML = S.storages.map(s =>
`<option value="${s.id}">${esc(s.name)}${s.freeStr ? ' (' + s.freeStr + ')' : ''}</option>`
).join('');
sel.value = String(S.activeStorageId || prev);
sel.style.display = '';
} else {
sel.style.display = 'none';
}
const stack = S.remoteNavStack || [];
const crumb = document.getElementById('remote-breadcrumb');
crumb.innerHTML = stack.slice(1).map((e, i) => {
const absIdx = i + 1;
return absIdx === stack.length - 1
? `<span class="crumb-cur">${esc(e.label)}</span>`
: `<span class="crumb-link" data-idx="${absIdx}">${esc(e.label)}</span><span class="crumb-sep"></span>`;
}).join('');
crumb.querySelectorAll('.crumb-link').forEach(el =>
el.addEventListener('click', () => call('navigate_remote_to', {index: +el.dataset.idx})));
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
document.getElementById('btn-new-folder').disabled = !S.connected;
}
function renderDeviceBar() {
const badge = document.getElementById('conn-badge');
const status = document.getElementById('device-status-text');
if (S.connected) {
const name = S.deviceName || t('device.nintendo_switch');
badge.className = 'badge connected';
badge.textContent = name;
status.textContent = t('device.connected', { name });
} else {
badge.className = 'badge disconnected';
badge.textContent = t('badge.no_device');
status.textContent = t('device.waiting');
}
}
function renderTransfers() {
const list = document.getElementById('transfers-list');
const label = document.getElementById('transfers-label');
const cancelAll= document.getElementById('btn-cancel-all');
const panel = document.getElementById('transfers-panel');
const active = (S.transfers || []).filter(t => t.state <= 1);
label.textContent = active.length
? tPlural('transfers.count', 'transfers.count_plural', active.length)
: t('transfers');
cancelAll.style.display = active.length ? '' : 'none';
cancelAll.textContent = t('btn.cancel_all');
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
panel.style.display = '';
const stateNames = {
0: t('transfer.queued'),
2: t('transfer.done'),
3: t('transfer.failed'),
4: t('transfer.cancelled'),
};
list.innerHTML = S.transfers.map(tx => {
const dir = tx.direction === 0 ? t('transfer.dl') : t('transfer.up');
const dc = tx.direction === 0 ? 'dir-dl' : 'dir-ul';
const pct = ((tx.progress || 0) * 100).toFixed(0);
const showBar = tx.state <= 1;
const statText = tx.state === 1 ? esc(tx.speedStr)
: tx.state === 3 ? '⚠ ' + esc(tx.error || t('transfer.failed'))
: (stateNames[tx.state] || '');
return `<div class="txfer-item">
<div class="txfer-row">
<span class="txfer-dir ${dc}">${esc(dir)}</span>
<span class="txfer-name" title="${esc(tx.filename)}">${esc(tx.filename)}</span>
<span class="txfer-stat">${statText}</span>
${showBar ? `<button class="txfer-cancel" data-id="${esc(tx.id)}">×</button>` : ''}
</div>
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></div>
<div class="txfer-meta">${pct}%${tx.etaStr ? ' · ' + tx.etaStr : ''}</div>` : ''}
</div>`;
}).join('');
list.querySelectorAll('.txfer-cancel').forEach(btn =>
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
}
function statusArgsMap(args) {
const a = args || [];
return {
name: a[0] || '',
error: a[0] || '',
label: a[0] || '',
n: a[0] || '',
};
}
function renderStatusBar() {
const rf = (S.remoteFiles || []).length;
document.getElementById('sb-left').textContent = S.connected
? tPlural('items.count', 'items.count_plural', rf)
: '';
document.getElementById('sb-right').textContent = '';
let center = '';
const skipStatus = S.statusKey === 'status.connected' || S.statusKey === 'status.disconnected';
if (S.statusTimer > 0 && S.statusKey && !skipStatus) {
center = t(S.statusKey, statusArgsMap(S.statusArgs));
} else if (S.connected) {
const act = (S.transfers || []).filter(tx => tx.state === 1);
if (act.length) {
const spd = act.reduce((s, tx) => s + (tx.speedBps || 0), 0);
const speed = spd > 1048576
? (spd/1048576).toFixed(0) + ' MB/s'
: (spd/1024).toFixed(0) + ' KB/s';
center = t('status.active', { n: act.length, speed });
}
}
document.getElementById('sb-center').textContent = center;
}
let _ctxFile = null;
let _ctxMode = 'file'; // 'file' | 'blank'
function positionCtxMenu(e) {
const m = document.getElementById('ctx-menu');
m.style.left = e.clientX + 'px';
m.style.top = e.clientY + 'px';
m.classList.remove('hidden');
}
function showRemoteCtxMenu(file, e) {
_ctxFile = file;
_ctxMode = 'file';
if (!S.selRemote.has(String(file.handle))) {
S.selRemote.clear();
S.selRemote.add(String(file.handle));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
document.getElementById('ctx-new-folder').style.display = '';
document.getElementById('ctx-sep-delete').style.display = '';
document.getElementById('ctx-delete').style.display = '';
positionCtxMenu(e);
}
function showBlankCtxMenu(e) {
if (!S.connected) return;
_ctxFile = null;
_ctxMode = 'blank';
document.getElementById('ctx-new-folder').style.display = '';
document.getElementById('ctx-sep-delete').style.display = 'none';
document.getElementById('ctx-delete').style.display = 'none';
positionCtxMenu(e);
}
document.addEventListener('click', e => {
if (e.target.closest('#ctx-menu')) return;
document.getElementById('ctx-menu').classList.add('hidden');
});
document.addEventListener('contextmenu', e => {
if (e.target.closest('.frow')) return;
if (e.target.closest('#remote-files') && S.connected) {
e.preventDefault();
showBlankCtxMenu(e);
return;
}
e.preventDefault();
document.getElementById('ctx-menu').classList.add('hidden');
});
window.omniClearSelection = function() {
const sel = window.getSelection();
if (sel && sel.rangeCount) sel.removeAllRanges();
};
function exportDragFilesForRow(row) {
if (!S.connected || !row) return [];
const files = sortedRemoteFiles();
const f = files[+row.dataset.idx];
if (!f) return [];
const key = String(f.handle);
const dragFiles = S.selRemote.has(key)
? files.filter(rf => S.selRemote.has(String(rf.handle)))
: [f];
return dragFiles.map(df => ({
handle: df.handle,
storageId: df.storageId,
name: df.name,
size: df.size || 0,
isDir: !!df.isDir,
}));
}
document.addEventListener('selectstart', e => {
if (e.target.closest('.file-list')) e.preventDefault();
}, true);
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (e.target.closest('.fname-edit')) return;
const row = e.target.closest('#remote-files .frow');
if (!row) return;
e.preventDefault();
const files = exportDragFilesForRow(row);
if (!files.length) return;
call('prepare_export_drag', { files, x: e.clientX, y: e.clientY });
}, true);
document.addEventListener('dragstart', e => {
if (e.target.closest('.file-list .frow')) e.preventDefault();
}, true);
document.addEventListener('DOMContentLoaded', async () => {
await initI18n();
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
document.getElementById('btn-new-folder') .addEventListener('click', () => createFolderInline());
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
document.getElementById('btn-open-picker') .addEventListener('click', () => call('open_file_picker'));
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
const bindCtx = (id, fn) => {
document.getElementById(id).addEventListener('click', e => {
e.stopPropagation();
document.getElementById('ctx-menu').classList.add('hidden');
fn();
});
};
bindCtx('ctx-new-folder', () => createFolderInline());
bindCtx('ctx-delete', async () => {
if (!_ctxFile || _ctxMode !== 'file') return;
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
const message = multi
? t('confirm.delete_items', { n: S.selRemote.size })
: t('confirm.delete', { label: `"${_ctxFile.name}"` });
if (await confirmDialog(message)) {
const handles = multi
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
: [_ctxFile.handle];
handles.forEach(h => call('delete_remote', {handle: h}));
}
document.getElementById('ctx-menu').classList.add('hidden');
});
document.addEventListener('keydown', e => {
if (e.target.matches('input,textarea,select')) return;
if (e.key === 'Backspace' || e.key === 'Delete') {
S.selRemote.forEach(h => call('delete_remote', {handle: +h}));
}
if (e.metaKey && e.key === 'r') {
e.preventDefault();
call('refresh_remote');
}
if (e.metaKey && e.key === 'a') {
e.preventDefault();
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
if (e.key === 'Escape') {
S.selRemote.clear();
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
}
});
poll();
updateDropZones();
window.addEventListener('resize', updateDropZones);
});