'use strict'; // ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret) async function viewEdit([repo, ...rawParts], opts = {}) { const parts = rawParts.filter(Boolean); const path = '/' + parts.join('/'), name = parts[parts.length - 1] || ''; const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join(''); document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor const v = $('#view'); v.innerHTML = `
${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}${esc(name)}

Opening…

`; if (!sf.token()) return (location.hash = '#/files'); const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } }; try { const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() }, body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) }); const conf = await r.json(); if (!r.ok) return fail(conf.error || 'Could not open document'); if (!window.DocsAPI) await new Promise((ok, no) => { const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js'; s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s); }); if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return; $('#oomsg').remove(); conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) }; editor = new DocsAPI.DocEditor('oo', conf); } catch (e) { fail(e.message); } } // ---- Files function viewFiles([repo, ...parts]) { if (!sf.token()) return filesLogin(); const dir = '/' + parts.join('/'); $('#view').innerHTML = `

Files

Loading…

`; libsP = loadLibs(repo); if (repo) loadDir(repo, dir); } function filesLogin(msg = '', needOtp = false) { $('#view').innerHTML = `

Files

Sign in to ${esc(new URL(cfg.seafile).host)}

${needOtp ? '' : ''}
${esc(msg)}
`; $('#lf').onsubmit = async e => { e.preventDefault(); const f = new FormData(e.target); try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); } catch (err) { filesLogin(err.message, !!err.otp || needOtp); } }; } async function loadLibs(active) { try { const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name)); libNames = Object.fromEntries(repos.map(r => [r.id, r.name])); libPerm = Object.fromEntries(repos.map(r => [r.id, r.permission])); $('#libs').innerHTML = `

Libraries

${repos.map(r => ` ${icon('files')} ${esc(r.name)}${r.encrypted ? 'Encrypted · ' : ''}${esc(r.type === 'repo' ? 'Mine' : r.owner_name || r.owner || 'Shared')}${icon('chev', 'chev')}`).join('') || '

No libraries

'}
`; $('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => { try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); } catch (e) { toast(e.message); } }); if (!active) return; const r = repos.find(x => x.id === active); if (r && r.encrypted) $('#fmain').innerHTML = '

This library is encrypted. Unlock it in the Seafile web UI first.

'; } catch (e) { $('#libs').innerHTML = `

${esc(e.message)}

`; if (!sf.token()) setTimeout(route, 800); } } // ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download) let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {}; const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']]; const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); // (one collator is much faster than localeCompare in a big folder) const nameCmp = (a, b) => collator.compare(a.name, b.name); function sortItems(items, s) { const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp; return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top : (a.type === 'dir' && s.k !== 'name' && s.k !== 'mtime') ? nameCmp(a, b) : s.d * by(a, b) || nameCmp(a, b)); } const badName = (n, items, self) => !n || n === '.' || n === '..' ? 'Enter a name' : /[\/\\]/.test(n) ? 'A name cannot contain / or \\' : n.length > 255 ? 'That name is too long' : items.some(x => x.name.toLowerCase() === n.toLowerCase() && x.name !== self) ? 'Something with that name is already here' : ''; const sfJson = (path, body) => sf.req(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const hereItems = (repo, dir) => (fv && fv.repo === repo && fv.dir === dir ? fv.items : []); // After something changed in a folder, redraw it if it is the one on screen. function afterChange(repo, dir) { if (fv && document.contains(fv.root) && fv.repo === repo && fv.dir === dir) { fv.sel.clear(); fv.selecting = false; fv.reload(); } } async function removeItems(repo, dir, items) { const enc = encodeURIComponent; try { await sfJson('/api/v2.1/repos/batch-delete-item/', { repo_id: repo, parent_dir: dir, dirents: items.map(x => x.name) }); } catch (e) { // older servers have no batch call: one at a time const failed = []; for (const it of items) { try { await sf.req(`/api2/repos/${enc(repo)}/${it.type === 'dir' ? 'dir' : 'file'}/?p=${enc(joinP(dir, it.name))}`, { method: 'DELETE' }); } catch { failed.push(it.name); } } if (failed.length) throw new Error('Could not delete: ' + failed.join(', ')); } } function deleteItems(repo, dir, items) { const one = items.length === 1; confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`, one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => { try { await removeItems(repo, dir, items); afterChange(repo, dir); if (!(await offerUndo(repo, dir, items, () => afterChange(repo, dir)))) toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); afterChange(repo, dir); } }); } async function transferItems(mode, repo, dir, names, dst) { await sfJson(`/api/v2.1/repos/sync-batch-${mode}-item/`, { src_repo_id: repo, src_parent_dir: dir, dst_repo_id: dst.repo, dst_parent_dir: dst.dir, src_dirents: names }); } function moveSheet(mode, repo, dir, items) { const names = items.map(x => x.name), folders = items.filter(x => x.type === 'dir').map(x => joinP(dir, x.name)), verb = mode === 'move' ? 'Move' : 'Copy'; chooseFolder(verb + (items.length === 1 ? ' “' + names[0] + '”' : ' ' + items.length + ' items') + ' to…', { repo, repoName: libNames[repo] || '', dir }, { okLabel: verb + ' here', hide: (r, d) => (r === repo && d === dir ? names : []), newFolder: true, blocked: (r, d) => r === repo && ((mode === 'move' && d === dir) || folders.some(f => d === f || d.startsWith(f + '/'))) }).then(async dst => { if (!dst) return; try { await transferItems(mode, repo, dir, names, dst); toast((mode === 'move' ? 'Moved' : 'Copied') + (items.length > 1 ? ' ' + items.length + ' items' : '')); } catch (e) { toast(e.message); } afterChange(repo, dir); }); } function renameItem(repo, path, name, dir, isDir) { textSheet('Rename', 'New name', 'Rename', async n => { if (n === name) return; try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${isDir ? 'dir' : 'file'}/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'rename', newname: n }) }); toast('Renamed'); } catch (e) { toast(e.message); } afterChange(repo, dir); }, name, n => badName(n, hereItems(repo, dir), name)); } async function downloadItem(repo, path) { try { window.open(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), '_blank', 'noopener'); } catch (e) { toast(e.message); } } // ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel const uq = { items: [], running: 0 }; const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up'); async function enqueueUploads(repo, dir, entries) { if (!entries.length) return; entries = await resolveUploadConflicts(repo, dir, entries); // a name that is already there: replace, keep both, or skip if (!entries.length) return; for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', replace: !!e.replace, pct: 0, state: 'wait', tries: 0 }); drawUpBar(); pumpUploads(); } function pumpUploads() { while (uq.running < 2) { const it = uq.items.find(x => x.state === 'wait'); if (!it) break; uq.running++; it.state = 'up'; sendUpload(it).then(() => { it.state = 'done'; }, err => { it.state = it.cancelled ? 'cancel' : 'fail'; it.err = err.message; }) .finally(() => { uq.running--; drawUpBar(); pumpUploads(); if (!upActive()) uploadsFinished(); }); } } async function sendUpload(it) { const enc = encodeURIComponent; for (;;) { it.tries++; it.pct = 0; try { const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry await new Promise((ok, no) => { const x = it.xhr = new XMLHttpRequest(), fd = new FormData(); fd.append('parent_dir', it.dir); fd.append('replace', it.replace ? '1' : '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name); x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1'); x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } }; x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 })); x.onerror = () => no(new Error('Connection lost')); x.onabort = () => no(Object.assign(new Error('Cancelled'), { fatal: true })); x.send(fd); }); return; } catch (e) { if (it.cancelled) throw e; if (e.fatal || !sf.token()) throw new Error(it.file.name + ': ' + e.message); if (!navigator.onLine) { it.waiting = true; drawUpBar(); await waitOnline(); it.waiting = false; it.tries = Math.max(0, it.tries - 1); } // no connection: wait for it instead of failing else if (it.tries >= 5) throw new Error(it.file.name + ': ' + e.message); else await sleep(Math.min(15000, 1500 * 2 ** (it.tries - 1))); } } } function uploadsFinished() { const done = uq.items.filter(x => x.state === 'done').length, bad = uq.items.filter(x => x.state === 'fail').length; if (done) toast(done + (done === 1 ? ' file' : ' files') + ' uploaded' + (bad ? ', ' + bad + ' failed' : '')); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); if (fv && document.contains(fv.root)) fv.reload(true); } function drawUpBar() { let b = $('#upbar'); const live = uq.items.filter(x => x.state === 'wait' || x.state === 'up'), bad = uq.items.filter(x => x.state === 'fail'); if (!live.length && !bad.length) { if (b) b.remove(); return; } if (!b) { b = document.createElement('div'); b.id = 'upbar'; document.body.appendChild(b); } if (live.length) { const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0]; const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100); b.innerHTML = `
${live.some(x => x.waiting) ? 'Waiting for a connection…' : 'Uploading ' + esc(live.length === 1 ? cur.file.name : live.length + ' files')}${pct}%${cur.tries > 1 && !cur.waiting ? ' · retrying' : ''}
`; $('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); }; } else { b.innerHTML = `
${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed${esc(bad[0].err || '')}
`; $('#upr').onclick = () => { bad.forEach(x => { x.state = 'wait'; x.tries = 0; x.cancelled = false; }); drawUpBar(); pumpUploads(); }; $('#upd').onclick = () => { uq.items = uq.items.filter(x => x.state !== 'fail'); drawUpBar(); }; } } addEventListener('beforeunload', e => { if (upActive()) { e.preventDefault(); e.returnValue = ''; } }); // Everything dropped from the computer, including whole folders (their structure is kept). async function droppedFiles(dt) { const ents = [...(dt.items || [])].map(i => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null)).filter(Boolean), out = []; if (!ents.length) return [...dt.files].map(file => ({ file, rel: '' })); const walk = async (en, rel) => { if (en.isFile) await new Promise(ok => en.file(f => { out.push({ file: f, rel }); ok(); }, ok)); else if (en.isDirectory) { const here = rel ? rel + '/' + en.name : en.name, rd = en.createReader(); let batch; do { batch = await new Promise(ok => rd.readEntries(ok, () => ok([]))); for (const c of batch) await walk(c, here); } while (batch.length); } }; for (const en of ents) await walk(en, ''); return out; } const dlUrls = new Map(); // direct download addresses, fetched ahead so a file can be dragged out to the desktop async function prefetchDl(repo, path) { const k = repo + path, c = dlUrls.get(k); if (c && Date.now() - c.t < 45 * 60e3) return; dlUrls.set(k, { t: Date.now() }); try { const u = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`); dlUrls.set(k, { t: Date.now(), url: new URL(u, cfg.seafile).href }); } catch { dlUrls.delete(k); } } async function loadDir(repo, dir) { const main = $('#fmain'); if (!main) return; main.innerHTML = '

Loading…

'; await libsP.catch(() => {}); if (!document.contains(main)) return; const enc = encodeURIComponent, ro = libPerm[repo] === 'r'; const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); const segs = dir.split('/').filter(Boolean); const crumbs = `` + segs.map((s, i) => `/`).join(''); const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null, limit: 300, stale: false }; const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 }); const byName = n => v.items.find(x => x.name === n); const selItems = () => v.items.filter(x => v.sel.has(x.name)); const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); }; main.innerHTML = `
${crumbs}

Loading…

`; const go = p => { location.hash = hash(p); }; const drawTop = () => { const n = v.sel.size, one = n === 1 ? selItems()[0] : null; $('#ftop').innerHTML = v.selecting ? `
${n ? n + ' selected' : 'Select items'}
${ro ? '' : ``} ${ro ? '' : ``}
` : `
${icon('back')}Libraries ${ro ? '' : ``}
`; }; const drawItems = () => { const list = visible(), box = $('#items'), ff = $('#ff'); ff.hidden = v.items.length < 8; const row = it => { const d = it.type === 'dir', on = v.sel.has(it.name); return `
${v.selecting ? `` : ''} ${v.selecting ? '' : ``}
`; }; box.innerHTML = (v.stale ? `

${v.stale === 'old' ? 'Showing the last saved list. ' : ''}${esc(v.staleWhy || '')}

` : '') + (list.length ? `
${list.slice(0, v.limit).map(row).join('')}
${list.length > v.limit ? `
` : ''}` : `

${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}

`); const fm = $('#fmore'); if (fm) fm.onclick = () => { v.limit += 300; drawItems(); }; const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0); $('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : ''; }; const redraw = () => { drawTop(); drawItems(); }; const toggle = (it, range) => { const vis = visible().map(x => x.name); if (range && v.last && vis.includes(v.last) && vis.includes(it.name)) { const a = vis.indexOf(v.last), b = vis.indexOf(it.name); vis.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(n => v.sel.add(n)); } else { v.sel.has(it.name) ? v.sel.delete(it.name) : v.sel.add(it.name); v.last = it.name; } v.selecting = true; redraw(); }; const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir); const openIt = it => it.type === 'dir' ? go(joinP(dir, it.name)) : openFile(repo, joinP(dir, it.name), it.name, dir); const oneActions = it => { const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }]; if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) }); if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) }); if (d) a.push({ label: 'Download as zip', icon: 'dl', run: () => zipDownload(repo, dir, [it.name]) }); if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) }); return a; }; const create = async (path, op, extra = {}, kind = 'file') => { try { await sf.req(`/api2/repos/${enc(repo)}/${kind}/?p=${enc(path)}`, { method: 'POST', body: new URLSearchParams({ operation: op, ...extra }), headers: FORM }); v.reload(true); } catch (e) { toast(e.message); } }; const actions = { all: () => { const vis = visible(); if (v.sel.size === vis.length) v.sel.clear(); else vis.forEach(x => v.sel.add(x.name)); redraw(); }, move: () => v.sel.size && moveSheet('move', repo, dir, selItems()), copy: () => v.sel.size && moveSheet('copy', repo, dir, selItems()), del: () => v.sel.size && deleteItems(repo, dir, selItems()), more: () => { const it = selItems()[0]; if (it) actionSheet(it.name, '', oneActions(it)); }, done: () => { v.selecting = false; v.sel.clear(); redraw(); }, sel: () => { v.selecting = true; redraw(); }, refresh: () => v.reload(), zip: () => v.sel.size && zipDownload(repo, dir, [...v.sel]), lib: () => actionSheet(dir === '/' ? (libNames[repo] || 'Library') : dir.split('/').pop(), 'This folder', [ { label: 'Search this library', icon: 'refresh', run: () => libSearch(repo) }, { label: 'Recycle bin', icon: 'trash', run: () => recycleBin(repo, dir) }, ...(dir === '/' ? [] : [{ label: 'Download this folder as zip', icon: 'dl', run: () => zipDownload(repo, dir.replace(/\/[^/]*$/, '') || '/', [dir.split('/').pop()]) }]), ...(v.items.length ? [{ label: 'Download everything here as zip', icon: 'dl', run: () => zipDownload(repo, dir, v.items.map(x => x.name)) }] : []), ]), sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort', run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); }, up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]), new: () => actionSheet('Create new', '', [ { label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir'), '', n => badName(n, v.items)) }, ...[['Document', 'docx'], ['Spreadsheet', 'xlsx'], ['Presentation', 'pptx'], ['Text file', 'txt']].map(([label, x]) => ({ label, icon: 'doc', run: () => textSheet('New ' + label.toLowerCase(), 'File name', 'Create', async n => { const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x; try { const name = await createFromTemplate(repo, dir, want, x); v.reload(true); if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir); } catch (e) { toast(e.message); } }, '', n => badName(n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x, v.items)) })), ]), }; v.actions = actions; v.selItems = selItems; v.openIt = openIt; v.visible = visible; v.one = oneActions; $('#ftop').onclick = e => { const b = e.target.closest('[data-a]'); if (b && !b.disabled && actions[b.dataset.a]) actions[b.dataset.a](); }; $('#ftop').onchange = e => { const pick = e.target.id === 'upf' || e.target.id === 'upd2' ? [...e.target.files] : null; if (!pick) return; enqueueUploads(repo, dir, pick.map(f => ({ file: f, rel: f.webkitRelativePath ? f.webkitRelativePath.split('/').slice(0, -1).join('/') : '' }))); e.target.value = ''; }; $('#ff').oninput = e => { v.filter = e.target.value; drawItems(); }; main.querySelector('.crumbs').onclick = e => { const b = e.target.closest('[data-go]'); if (b) go(b.dataset.go); }; // tap / click / ctrl-click / shift-click, right-click, and long-press on a touch screen const box = $('#items'); let suppress = false, lastTouch = 0, lp = null; box.onclick = e => { if (suppress) { suppress = false; return; } const r = e.target.closest('.fr'); if (!r) return; const it = byName(r.dataset.n); if (!it) return; if (e.target.closest('[data-more]')) return itemSheet(it); if (v.selecting || e.metaKey || e.ctrlKey || e.shiftKey) return toggle(it, e.shiftKey); openIt(it); }; box.oncontextmenu = e => { const r = e.target.closest('.fr'); if (!r) return; e.preventDefault(); if (Date.now() - lastTouch < 1200) return; const it = byName(r.dataset.n); if (!it) return; if (v.selecting && v.sel.size > 1 && v.sel.has(it.name)) return actionSheet(v.sel.size + ' selected', '', [ ...(ro ? [] : [{ label: 'Move', icon: 'files', run: actions.move }]), { label: 'Copy', icon: 'copy', run: actions.copy }, ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: actions.del }]) ]); itemSheet(it); }; box.onpointerdown = e => { if (e.pointerType !== 'touch') return; lastTouch = Date.now(); const r = e.target.closest('.fr'); if (!r || v.selecting) return; const x0 = e.clientX, y0 = e.clientY; const stop = () => { clearTimeout(lp); box.removeEventListener('pointermove', mv); }; const mv = m => { if (Math.abs(m.clientX - x0) + Math.abs(m.clientY - y0) > 10) stop(); }; box.addEventListener('pointermove', mv); box.addEventListener('pointerup', stop, { once: true }); box.addEventListener('pointercancel', stop, { once: true }); lp = setTimeout(() => { stop(); const it = byName(r.dataset.n); if (!it) return; suppress = true; v.sel.add(it.name); v.last = it.name; v.selecting = true; redraw(); try { navigator.vibrate && navigator.vibrate(12); } catch {} }, 450); }; box.onmouseover = e => { const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (it && it.type !== 'dir' && !ro) prefetchDl(repo, joinP(dir, it.name)); }; // Drag: out to the desktop (one file, Chrome/Edge), onto a folder or breadcrumb to move (Alt/Option copies), in from the computer to upload. box.ondragstart = e => { const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (!it) return; const names = v.sel.has(it.name) && v.sel.size ? [...v.sel] : [it.name]; e.dataTransfer.setData('application/x-hub-items', JSON.stringify({ repo, dir, names })); e.dataTransfer.effectAllowed = 'copyMove'; const c = it.type !== 'dir' && names.length === 1 && dlUrls.get(repo + joinP(dir, it.name)); if (c && c.url) e.dataTransfer.setData('DownloadURL', `application/octet-stream:${it.name.replace(/:/g, '-')}:${c.url}`); else if (it.type !== 'dir') prefetchDl(repo, joinP(dir, it.name)); }; const target = e => { const t = e.target.closest('.fr, [data-go]'); if (!t) return null; if (t.dataset.go) return { el: t, path: t.dataset.go }; const it = byName(t.dataset.n); return it && it.type === 'dir' ? { el: t, path: joinP(dir, it.name) } : null; }; const clearDrop = () => main.querySelectorAll('.dropt').forEach(x => x.classList.remove('dropt')), isFiles = e => [...(e.dataTransfer.types || [])].includes('Files'); main.ondragover = e => { const mine = [...(e.dataTransfer.types || [])].includes('application/x-hub-items'); if (!mine && !isFiles(e) || ro) return; e.preventDefault(); clearDrop(); const t = target(e); if (t) t.el.classList.add('dropt'); else if (isFiles(e)) main.classList.add('dropt'); e.dataTransfer.dropEffect = mine && e.altKey ? 'copy' : mine ? 'move' : 'copy'; }; main.ondragleave = e => { if (!main.contains(e.relatedTarget)) { clearDrop(); main.classList.remove('dropt'); } }; main.ondrop = async e => { if (ro) return; e.preventDefault(); clearDrop(); main.classList.remove('dropt'); const t = target(e), mine = e.dataTransfer.getData('application/x-hub-items'); if (mine) { const { names } = JSON.parse(mine); if (!t || t.path === dir || names.some(n => joinP(dir, n) === t.path)) return; try { await transferItems(e.altKey ? 'copy' : 'move', repo, dir, names, { repo, dir: t.path }); toast((e.altKey ? 'Copied' : 'Moved') + (names.length > 1 ? ' ' + names.length + ' items' : '')); } catch (err) { toast(err.message); } return afterChange(repo, dir); } if (isFiles(e)) { const files = await droppedFiles(e.dataTransfer); enqueueUploads(repo, t ? t.path : dir, files); } }; v.reload = async quiet => { const tok = v.tok = Symbol(), saved = dcGet(repo, dir); const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); }; if (!quiet) { if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it else $('#items').innerHTML = '

Loading…

'; } try { const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`); if (v.tok !== tok || !document.contains(main)) return; v.items = items; v.stale = false; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n); dcPut(repo, dir, items); redraw(); restore(); } catch (e) { if (v.tok !== tok || !document.contains(main)) return; if (!sf.token()) return route(); const why = navigator.onLine ? e.message : 'You are offline.'; if (saved && v.items.length) { // keep showing the saved list instead of an error v.stale = 'old'; v.staleWhy = why + ' Saved ' + fmtDate(saved.t / 1000) + '. '; redraw(); restore(); const n = $('#items .sub'); if (n) { n.insertAdjacentHTML('beforeend', 'Try again'); $('#fretry2').onclick = ev => { ev.preventDefault(); v.reload(); }; } return; } $('#items').innerHTML = `

${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}

`; $('#fretry').onclick = () => v.reload(); } }; drawTop(); v.reload(); } const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n; // Keep the folder fresh when you come back to the tab, and add Finder-style keys. document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && fv && document.contains(fv.root) && !upActive() && !document.getElementById('sheet')) fv.reload(true); }); addEventListener('keydown', e => { if (!fv || !document.contains(fv.root) || document.getElementById('sheet') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) return; const mod = e.metaKey || e.ctrlKey, n = fv.sel.size, one = n === 1 ? fv.selItems()[0] : null, a = fv.actions; if (e.key === 'Escape' && (n || fv.selecting)) { a.done(); } else if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); fv.selecting = true; fv.visible().forEach(x => fv.sel.add(x.name)); a.sel(); } else if ((e.key === 'Delete' || e.key === 'Backspace') && n) { e.preventDefault(); a.del(); } else if (e.key === 'F2' && one) { e.preventDefault(); const p = joinP(fv.dir, one.name); renameItem(fv.repo, p, one.name, fv.dir, one.type === 'dir'); } else if (e.key === 'Enter' && one) fv.openIt(one); });