Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | /* ══ FILE MANAGER VIEW — FileZilla-style Dual Pane ════════════════════════ |
| 2 | * Split view with source and destination panels. |
| 3 | * ES5 compatible for PS5 browser. |
| 4 | * ═════════════════════════════════════════════════════════════════════════ */ |
| 5 | |
| 6 | var ZFTPD = ZFTPD || {}; |
| 7 | |
| 8 | (function (Z) { |
| 9 | 'use strict'; |
| 10 | |
| 11 | var D = document; |
| 12 | var $ = Z.$; |
| 13 | var ICO = Z.ICO; |
| 14 | |
| 15 | var fm = {}; |
| 16 | var _leftPath = '/'; |
| 17 | var _rightPath = '/'; |
| 18 | var _leftEntries = []; |
| 19 | var _rightEntries = []; |
| 20 | var _leftSelected = []; |
| 21 | var _rightSelected = []; |
| 22 | var _activePanel = 'left'; |
| 23 | |
| 24 | fm.init = function () { |
| 25 | _leftPath = Z.state.fmLeftPath || '/'; |
| 26 | _rightPath = Z.state.fmRightPath || '/'; |
| 27 | loadPanel('left', _leftPath); |
| 28 | loadPanel('right', _rightPath); |
| 29 | wireButtons(); |
| 30 | }; |
| 31 | |
| 32 | /* ── Load a panel ── */ |
| 33 | function loadPanel(side, path) { |
| 34 | path = Z.norm(path); |
| 35 | if (side === 'left') { _leftPath = path; Z.state.fmLeftPath = path; } |
| 36 | else { _rightPath = path; Z.state.fmRightPath = path; } |
| 37 | |
| 38 | var body = $('fm-' + side + '-body'); |
| 39 | if (body) body.innerHTML = '<div style="padding:20px;text-align:center;"><div class="loader" style="margin:0 auto;"></div></div>'; |
| 40 | |
| 41 | renderPanelPath(side, path); |
| 42 | |
| 43 | Z.api.list(path).then(function (d) { |
| 44 | var entries = (d && Array.isArray(d.entries)) ? d.entries : []; |
| 45 | /* Sort: dirs first, then alpha */ |
| 46 | entries.sort(function (a, b) { |
| 47 | var da = a.type === 'directory' ? 0 : 1; |
| 48 | var db = b.type === 'directory' ? 0 : 1; |
| 49 | if (da !== db) return da - db; |
| 50 | return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()); |
| 51 | }); |
| 52 | |
| 53 | if (side === 'left') { _leftEntries = entries; _leftSelected = []; } |
| 54 | else { _rightEntries = entries; _rightSelected = []; } |
| 55 | |
| 56 | renderPanel(side, entries); |
| 57 | updateFooter(side, entries); |
| 58 | }).catch(function () { |
| 59 | if (body) body.innerHTML = '<div style="padding:20px;text-align:center;color:var(--er);font-size:12px;">Failed to load</div>'; |
| 60 | }); |
| 61 | } |
| 62 | |
| 63 | /* ── Render panel breadcrumb ── */ |
| 64 | function renderPanelPath(side, path) { |
| 65 | var bc = $('fm-' + side + '-path'); |
| 66 | if (!bc) return; |
| 67 | bc.innerHTML = ''; |
| 68 | |
| 69 | var root = D.createElement('span'); |
| 70 | root.className = 'crumb' + (path === '/' ? ' act' : ''); |
| 71 | root.textContent = '/'; |
| 72 | root.onclick = function () { loadPanel(side, '/'); }; |
| 73 | bc.appendChild(root); |
| 74 | |
| 75 | var parts = Z.norm(path).split('/'); |
| 76 | var acc = ''; |
| 77 | for (var i = 0; i < parts.length; i++) { |
| 78 | if (!parts[i]) continue; |
| 79 | acc += '/' + parts[i]; |
| 80 | var sep = D.createElement('span'); |
| 81 | sep.className = 'cr-sep'; |
| 82 | sep.textContent = '/'; |
| 83 | bc.appendChild(sep); |
| 84 | var seg = D.createElement('span'); |
| 85 | seg.className = 'crumb' + (acc === path ? ' act' : ''); |
| 86 | seg.textContent = parts[i]; |
| 87 | (function (cp) { seg.onclick = function () { loadPanel(side, cp); }; })(acc); |
| 88 | bc.appendChild(seg); |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /* ── Render panel file rows ── */ |
| 93 | function renderPanel(side, entries) { |
| 94 | var body = $('fm-' + side + '-body'); |
| 95 | if (!body) return; |
| 96 | body.innerHTML = ''; |
| 97 | |
| 98 | var path = side === 'left' ? _leftPath : _rightPath; |
| 99 | |
| 100 | /* Parent directory row */ |
| 101 | if (path !== '/') { |
| 102 | var parentRow = D.createElement('div'); |
| 103 | parentRow.className = 'fm-row'; |
| 104 | parentRow.innerHTML = |
| 105 | '<div class="fm-row-ico fi-dir">' + ICO.arrowUp + '</div>' + |
| 106 | '<div class="fm-row-name">..</div>' + |
| 107 | '<div class="fm-row-size"></div>'; |
| 108 | parentRow.ondblclick = function () { |
| 109 | loadPanel(side, Z.parent(path) || '/'); |
| 110 | }; |
| 111 | body.appendChild(parentRow); |
| 112 | } |
| 113 | |
| 114 | if (!entries.length) { |
| 115 | body.innerHTML += '<div style="padding:16px;text-align:center;color:var(--tx3);font-size:11px;">Empty</div>'; |
| 116 | return; |
| 117 | } |
| 118 | |
| 119 | for (var i = 0; i < entries.length; i++) { |
| 120 | var e = entries[i]; |
| 121 | var isDir = e.type === 'directory'; |
| 122 | var fullPath = Z.join(path, e.name); |
| 123 | |
| 124 | var row = D.createElement('div'); |
| 125 | row.className = 'fm-row'; |
| 126 | row.setAttribute('data-index', i); |
| 127 | row.setAttribute('data-path', fullPath); |
| 128 | row.setAttribute('data-name', e.name); |
| 129 | row.setAttribute('data-dir', isDir ? '1' : '0'); |
| 130 | |
| 131 | var cat = Z.fileCategory(e.name, isDir); |
| 132 | row.innerHTML = |
| 133 | '<div class="fm-row-ico fi-' + cat + '">' + (isDir ? ICO.folder : ICO.file) + '</div>' + |
| 134 | '<div class="fm-row-name" title="' + e.name + '">' + e.name + '</div>' + |
| 135 | '<div class="fm-row-size">' + (isDir ? '' : Z.bytes(e.size || 0)) + '</div>'; |
| 136 | |
| 137 | /* Click to select */ |
| 138 | (function (r, idx, s) { |
| 139 | r.onclick = function (ev) { |
| 140 | _activePanel = s; |
| 141 | var sel = s === 'left' ? _leftSelected : _rightSelected; |
| 142 | if (ev.ctrlKey || ev.metaKey) { |
| 143 | var pos = sel.indexOf(idx); |
| 144 | if (pos >= 0) sel.splice(pos, 1); |
| 145 | else sel.push(idx); |
| 146 | } else { |
| 147 | if (s === 'left') _leftSelected = [idx]; |
| 148 | else _rightSelected = [idx]; |
| 149 | sel = s === 'left' ? _leftSelected : _rightSelected; |
| 150 | } |
| 151 | updateSelection(s); |
| 152 | }; |
| 153 | r.ondblclick = function () { |
| 154 | var isD = r.getAttribute('data-dir') === '1'; |
| 155 | if (isD) loadPanel(s, r.getAttribute('data-path')); |
| 156 | else window.location.href = Z.api.downloadUrl(r.getAttribute('data-path')); |
| 157 | }; |
| 158 | })(row, i, side); |
| 159 | |
| 160 | body.appendChild(row); |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | /* ── Update selection highlighting ── */ |
| 165 | function updateSelection(side) { |
| 166 | var body = $('fm-' + side + '-body'); |
| 167 | if (!body) return; |
| 168 | var sel = side === 'left' ? _leftSelected : _rightSelected; |
| 169 | var rows = body.querySelectorAll('.fm-row[data-index]'); |
| 170 | for (var i = 0; i < rows.length; i++) { |
| 171 | var idx = parseInt(rows[i].getAttribute('data-index'), 10); |
| 172 | rows[i].classList.toggle('selected', sel.indexOf(idx) >= 0); |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | /* ── Update footer stats ── */ |
| 177 | function updateFooter(side, entries) { |
| 178 | var footer = $('fm-' + side + '-footer'); |
| 179 | if (!footer) return; |
| 180 | var dirs = 0, files = 0, totalSize = 0; |
| 181 | for (var i = 0; i < entries.length; i++) { |
| 182 | if (entries[i].type === 'directory') dirs++; |
| 183 | else { files++; totalSize += entries[i].size || 0; } |
| 184 | } |
| 185 | footer.innerHTML = '<b>' + dirs + '</b> folders, <b>' + files + '</b> files \u2014 ' + Z.bytes(totalSize); |
| 186 | } |
| 187 | |
| 188 | /* ── Wire center action buttons ── */ |
| 189 | function wireButtons() { |
| 190 | var copyRight = $('fm-copy-right'); |
| 191 | if (copyRight) copyRight.onclick = function () { doCopy('left', 'right'); }; |
| 192 | var copyLeft = $('fm-copy-left'); |
| 193 | if (copyLeft) copyLeft.onclick = function () { doCopy('right', 'left'); }; |
| 194 | var delBtn = $('fm-delete'); |
| 195 | if (delBtn) delBtn.onclick = function () { doDeleteSelected(); }; |
| 196 | var refreshLeft = $('fm-refresh-left'); |
| 197 | if (refreshLeft) refreshLeft.onclick = function () { loadPanel('left', _leftPath); }; |
| 198 | var refreshRight = $('fm-refresh-right'); |
| 199 | if (refreshRight) refreshRight.onclick = function () { loadPanel('right', _rightPath); }; |
| 200 | } |
| 201 | |
| 202 | /* ── Copy selected files from one panel to the other ── */ |
| 203 | function doCopy(fromSide, toSide) { |
| 204 | if (!Z.ensureTransferIdle()) return; |
| 205 | var sel = fromSide === 'left' ? _leftSelected : _rightSelected; |
| 206 | var entries = fromSide === 'left' ? _leftEntries : _rightEntries; |
| 207 | var fromPath = fromSide === 'left' ? _leftPath : _rightPath; |
| 208 | var toPath = toSide === 'left' ? _leftPath : _rightPath; |
| 209 | |
| 210 | if (!sel.length) { |
| 211 | Z.toast('Select files first', 'wn'); |
| 212 | return; |
| 213 | } |
| 214 | |
| 215 | var i = 0; |
| 216 | var cancelled = false; |
| 217 | |
| 218 | function next() { |
| 219 | if (cancelled || i >= sel.length) { |
| 220 | Z.hideTransferLock(); |
| 221 | loadPanel(toSide, toPath); |
| 222 | if (!cancelled && i > 0) { |
| 223 | Z.notify('Copy complete', i + ' items copied to ' + toPath, 'ok'); |
| 224 | /* Clear selection */ |
| 225 | if (fromSide === 'left') _leftSelected = []; else _rightSelected = []; |
| 226 | loadPanel(fromSide, fromPath); |
| 227 | } |
| 228 | return; |
| 229 | } |
| 230 | var entry = entries[sel[i++]]; |
| 231 | if (!entry) { next(); return; } |
| 232 | |
| 233 | var srcPath = Z.join(fromPath, entry.name); |
| 234 | |
| 235 | Z.showTransferLock({ |
| 236 | label: 'COPYING', |
| 237 | filename: entry.name, |
| 238 | dest: toPath, |
| 239 | onCancel: function () { |
| 240 | cancelled = true; |
| 241 | Z.api.copyCancel().catch(function(){}); // Assuming there's a cancel endpoint |
| 242 | Z.hideTransferLock(); |
| 243 | Z.notify('Copy cancelled', entry.name, 'wn'); |
| 244 | } |
| 245 | }); |
| 246 | |
| 247 | /* Fake progress based on time for now, or use an API polling if available */ |
| 248 | var startTime = Date.now(); |
| 249 | var fakeProgressInterval = setInterval(function() { |
| 250 | var elapsed = Math.floor((Date.now() - startTime) / 1000); |
| 251 | // Z.api.copyProgress() would go here in a real implementation |
| 252 | Z.updateTransferLock({ elapsed: elapsed + 's' }); |
| 253 | }, 1000); |
| 254 | |
| 255 | Z.api.copy(srcPath, toPath, entry.size || 0).then(function () { |
| 256 | clearInterval(fakeProgressInterval); |
| 257 | next(); |
| 258 | }).catch(function (e) { |
| 259 | clearInterval(fakeProgressInterval); |
| 260 | Z.hideTransferLock(); |
| 261 | Z.notify('Copy failed', entry.name + ': ' + e.message, 'er'); |
| 262 | }); |
| 263 | } |
| 264 | next(); |
| 265 | } |
| 266 | |
| 267 | /* ── Delete selected files in active panel ── */ |
| 268 | function doDeleteSelected() { |
| 269 | var sel = _activePanel === 'left' ? _leftSelected : _rightSelected; |
| 270 | var entries = _activePanel === 'left' ? _leftEntries : _rightEntries; |
| 271 | var path = _activePanel === 'left' ? _leftPath : _rightPath; |
| 272 | |
| 273 | if (!sel.length) { |
| 274 | Z.toast('Select files first', 'wn'); |
| 275 | return; |
| 276 | } |
| 277 | |
| 278 | var names = sel.map(function (idx) { return entries[idx] ? entries[idx].name : ''; }).join(', '); |
| 279 | Z.modal.confirm('Delete ' + sel.length + ' item(s)', names, true).then(function (ok) { |
| 280 | if (!ok) return; |
| 281 | var i = 0; |
| 282 | function next() { |
| 283 | if (i >= sel.length) { |
| 284 | loadPanel(_activePanel, path); |
| 285 | Z.toast('Deleted', 'ok'); |
| 286 | return; |
| 287 | } |
| 288 | var entry = entries[sel[i++]]; |
| 289 | if (!entry) { next(); return; } |
| 290 | var fullPath = Z.join(path, entry.name); |
| 291 | Z.api.del(fullPath, entry.type === 'directory').then(function () { |
| 292 | next(); |
| 293 | }).catch(function (e) { |
| 294 | Z.toast('Delete failed: ' + e.message, 'er'); |
| 295 | }); |
| 296 | } |
| 297 | next(); |
| 298 | }); |
| 299 | } |
| 300 | |
| 301 | Z.fileManager = fm; |
| 302 | |
| 303 | })(ZFTPD); |
| 304 |