diff --git a/public/assets/boot/ctrl_boot_frontoffice.js b/public/assets/boot/ctrl_boot_frontoffice.js index d15e8640..50ea666f 100644 --- a/public/assets/boot/ctrl_boot_frontoffice.js +++ b/public/assets/boot/ctrl_boot_frontoffice.js @@ -6,6 +6,9 @@ import { report } from "../helpers/log.js"; export default async function main() { try { + let config = {}; + // await Config.refresh() + await Promise.all([ // procedure with no outside dependencies setup_translation(), setup_xdg_open(), @@ -16,10 +19,10 @@ export default async function main() { setup_loader(), setup_history(), ]); - // await Config.refresh() await Promise.all([ // procedure with dependency on config // setup_chromecast() // TODO + setup_base(config), ]); window.dispatchEvent(new window.Event("pagechange")); @@ -68,6 +71,13 @@ async function setup_device() { }); } +async function setup_base(config) { + // TODO: base as config in admin + const $meta = document.createElement("base"); + $meta.setAttribute("href", location.origin); + document.head.appendChild($meta); +} + // async function setup_sw() { // if (!("serviceWorker" in window.navigator)) return; diff --git a/public/assets/lib/skeleton/router.js b/public/assets/lib/skeleton/router.js index e8b8dd3b..7175ecf4 100644 --- a/public/assets/lib/skeleton/router.js +++ b/public/assets/lib/skeleton/router.js @@ -25,9 +25,9 @@ export async function navigate(href) { const trimPrefix = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length) : value; export function currentRoute(r, notFoundRoute) { - const currentRoute = "/" + trimPrefix( + const currentRoute = trimPrefix( window.location.pathname, - window.document.head.querySelector("base")?.getAttribute("href") || "/" + window.document.head.querySelector("base")?.getAttribute("href") || "" ); for (const routeKey in r) { if (new RegExp("^" + routeKey + "$").test(currentRoute)) { diff --git a/public/assets/pages/filespage/cache_transcient.js b/public/assets/pages/filespage/cache_transcient.js deleted file mode 100644 index 01a9baa3..00000000 --- a/public/assets/pages/filespage/cache_transcient.js +++ /dev/null @@ -1,350 +0,0 @@ -import { onDestroy } from "../../lib/skeleton/index.js"; -import rxjs from "../../lib/rx.js"; -import fscache from "./cache.js"; -import { extractPath, isDir } from "./helper.js"; - -/* - * The transcient cache is used to rerender the list of files in a particular location. That's used - * when the user is doing either of a touch, mkdir, rm or mv. It is split onto 2 parts: - * - the virtualFiles$: which are things we want to display in addition to what is currently - * visible on the screen - * - the mutationFiles$: which are things already on the screen which we need to mutate. For - * example when we want a particular file to show a loading spinner - */ - -const virtualFiles$ = new rxjs.BehaviorSubject({ - // "/tmp/": [], - // "/home/": [{ name: "test", type: "directory" }] -}); -const mutationFiles$ = new rxjs.BehaviorSubject({ - // "/home/": [{ name: "test", fn: (file) => file, ...] -}); - -export function touch(ajax$, path) { - const [basepath, filename] = extractPath(path); - const file = { - name: filename, - type: "file", - size: 0, - time: new Date().getTime(), - }; - stateAdd(virtualFiles$, basepath, { - ...file, - loading: true, - }); - const onSuccess = async () => { - removeLoading(virtualFiles$, basepath, filename); - onDestroy(() => statePop(virtualFiles$, basepath, filename)); - await fscache().update(basepath, ({ files, ...rest }) => ({ - files: files.concat([file]), - ...rest, - })); - }; - const onFailure = (err, caught) => { - statePop(virtualFiles$, basepath, filename); - return rxjs.of(fscache().remove(basepath)).pipe( - rxjs.mergeMap(() => rxjs.EMPTY), - ); - }; - return ajax$.pipe( - rxjs.mergeMap(onSuccess), - rxjs.catchError(onFailure), - ); -} - -export function mkdir(ajax$, path) { - const [basepath, dirname] = extractPath(path); - const file = { - name: dirname, - type: "directory", - size: 0, - time: new Date().getTime(), - }; - stateAdd(virtualFiles$, basepath, { - ...file, - loading: true, - }); - const onSuccess = async () => { - removeLoading(virtualFiles$, basepath, dirname); - onDestroy(() => statePop(virtualFiles$, basepath, dirname)); - await fscache().update(basepath, ({ files, ...rest }) => ({ - files: files.concat([file]), - ...rest, - })); - }; - const onFailure = () => { - statePop(virtualFiles$, basepath, dirname); - return rxjs.of(fscache().remove(basepath)).pipe( - rxjs.mergeMap(() => rxjs.EMPTY), - ); - }; - return ajax$.pipe( - rxjs.mergeMap(onSuccess), - rxjs.catchError(onFailure), - ); -} - -export function save(ajax$, path, size) { - const [basepath, filename] = extractPath(path); - const file = { - name: dirname, - type: "file", - size, - time: new Date().getTime(), - }; - stateAdd(virtualFiles$, basepath, { - ...file, - loading: true, - }); - const onSuccess = async () => { - removeLoading(virtualFiles$, basepath, filename); - onDestroy(() => statePop(virtualFiles$, basepath, filename)); - await fscache().update(basepath, ({ files, ...rest }) => ({ - files: files.concat([file]), - ...rest, - })); - }; - const onFailure = () => { - statePop(virtualFiles$, basepath, dirname); - return rxjs.EMPTY; - }; - return ajax$.pipe( - rxjs.tap(onSuccess), - rxjs.catchError(onFailure), - ); -} - -export function rm(ajax$, ...paths) { - if (paths.length === 0) return rxjs.of(null); - const arr = new Array(paths.length * 2); - let basepath = null; - for (let i=0;i { - for (let i=0;i { - stateAdd(mutationFiles$, basepath, { - name: basepath, - fn: (file) => { - for (let i=0;i statePop(mutationFiles$, basepath, basepath)); - await Promise.all(paths.map((path) => fscache().remove(path, false))); - await fscache().update(basepath, ({ files, ...rest }) => ({ - files: files.filter(({ name }) => { - for (let i=0;i { - stateAdd(mutationFiles$, basepath, { - name: basepath, - fn: (file) => { - for (let i=0;i { - if (file.name === fromName) { - file.loading = true; - file.name = toName; - type = file.type; - } - return file; - }, - }); - } else { - stateAdd(mutationFiles$, fromBasepath, { - name: fromName, - fn: (file) => { - if (file.name === fromName) { - file.loading = true; - file.last = true; - type = file.type; - } - return file; - }, - }); - stateAdd(virtualFiles$, toBasepath, { - name: toName, - loading: true, - type, - }); - } - const onSuccess = async () => { - fscache().remove(fromPath, false); - if (fromBasepath === toBasepath) { - stateAdd(mutationFiles$, fromBasepath, { - name: fromName, - fn: (file) => { - if (file.name === toName) delete file.loading; - return file; - }, - }); - await fscache().update(fromBasepath, ({ files, ...rest }) => { - return { - files: files.map((file) => { - if (file.name === fromName) { - file.name = toName; - } - return file; - }), - ...rest, - }; - }); - } else { - stateAdd(mutationFiles$, fromBasepath, { - name: fromName, - fn: (file) => { - if (file.name === fromName) return null; - return file; - }, - }); - onDestroy(() => statePop(mutationFiles$, fromBasepath, fromName)); - statePop(virtualFiles$, toBasepath, toName); - await fscache().update(fromBasepath, ({ files, ...rest }) => ({ - files: files.filter((file) => file.name === fromName ? false : true), - ...rest, - })) - await fscache().update(toBasepath, ({ files, ...rest }) => ({ - files: files.concat([{ - name: fromName, - time: new Date().getTime(), - type, - }]), - ...rest, - })); - if (isDir(fromPath)) await fscache.remove(fromPath); - } - }; - const onFailure = () => { - statePop(mutationFiles$, fromBasepath, fromName); - if (fromBasepath !== toBasepath) { - statePop(virtualFiles$, toBasepath, toName); - } - return rxjs.EMPTY; - }; - return ajax$.pipe( - rxjs.tap(onSuccess), - rxjs.catchError(onFailure), - ); -} - -export function ls(path) { - return rxjs.pipe( - // case1: virtual files = additional files we want to see displayed in the UI - rxjs.switchMap(({ files, ...res }) => virtualFiles$.pipe(rxjs.mergeMap((virtualFiles) => { - const shouldContinue = !!(virtualFiles[path] && virtualFiles[path].length > 0); - if (!shouldContinue) return rxjs.of({ ...res, files }); - return rxjs.of({ - ...res, - files: files.concat(virtualFiles[path]), - }); - }))), - // case2: file mutation = update a file state, typically to add a loading state to an - // file or remove it entirely - rxjs.switchMap(({ files, ...res }) => mutationFiles$.pipe(rxjs.mergeMap((fns) => { - const shouldContinue = !!(fns[path] && fns[path].length > 0); - if (!shouldContinue) return rxjs.of({ ...res, files }); - for (let i=files.length-1; i>=0; i--) { - for (let j=0; j name !== filename) - if (newArr.length === 0) { - const newState = { ...behavior.value }; - delete newState[path]; - behavior.next(newState); - return; - } - behavior.next({ - ...behavior.value, - [path]: newArr, - }); -} - -function removeLoading(behavior, path, filename) { - const arr = behavior.value[path]; - if (!arr) return; - virtualFiles$.next({ - ...virtualFiles$.value, - [path]: arr.map((file) => { - if (file.name === filename) delete file.loading; - return file; - }), - }); -} diff --git a/public/assets/pages/filespage/ctrl_newitem.js b/public/assets/pages/filespage/ctrl_newitem.js index 6849c7ed..bd925fe2 100644 --- a/public/assets/pages/filespage/ctrl_newitem.js +++ b/public/assets/pages/filespage/ctrl_newitem.js @@ -6,7 +6,17 @@ import { loadCSS } from "../../helpers/loader.js"; import { getAction$, setAction } from "./state_newthing.js"; import { currentPath } from "./helper.js"; -import { mkdir, touch } from "./model_files.js"; +import { mkdir as mkdir$, touch as touch$ } from "./model_files.js"; +import { mkdir as mkdirVL, touch as touchVL, withVirtualLayer } from "./model_virtual_layer.js"; + +const touch = (path) => withVirtualLayer( + touch$(path), + touchVL(path), +); +const mkdir = (path) => withVirtualLayer( + mkdir$(path), + mkdirVL(path), +); export default async function(render) { const $node = createElement(` diff --git a/public/assets/pages/filespage/ctrl_submenu.js b/public/assets/pages/filespage/ctrl_submenu.js index a8d92ee3..70df225b 100644 --- a/public/assets/pages/filespage/ctrl_submenu.js +++ b/public/assets/pages/filespage/ctrl_submenu.js @@ -21,14 +21,26 @@ import { getAction$, setAction } from "./state_newthing.js"; import { setState, getState$ } from "./state_config.js"; import { clearCache } from "./cache.js"; import { getPermission, calculatePermission } from "./model_acl.js"; -import { rm, mv } from "./model_files.js"; import { currentPath, extractPath } from "./helper.js"; +import { rm as rm$, mv as mv$ } from "./model_files.js"; +import { rm as rmVL, mv as mvVL, withVirtualLayer } from "./model_virtual_layer.js"; + const modalOpt = { withButtonsRight: t("OK"), withButtonsLeft: t("CANCEL"), }; +const rm = (...paths) => withVirtualLayer( + rm$(...paths), + rmVL(...paths), +); + +const mv = (from, to) => withVirtualLayer( + mv$(from, to), + mvVL(from, to), +); + export default async function(render) { const $page = createElement(`
@@ -127,7 +139,7 @@ function componentLeft(render, { $scroll }) { createModal(modalOpt), basename(expandSelection()[0].path.replace(new RegExp("/$"), "")), )), - rxjs.mergeMap((val) => { + rxjs.mergeMap((val) => { // TODO: migrate to transcient impl const path = expandSelection()[0].path; const [basepath, filename] = extractPath(path); clearSelection(); @@ -141,7 +153,7 @@ function componentLeft(render, { $scroll }) { createModal(modalOpt), basename(expandSelection()[0].path.replace(new RegExp("/$"), "")).substr(0, 15), )), - rxjs.mergeMap((val) => { + rxjs.mergeMap((val) => { // TODO: migrate to transcient impl const selection = expandSelection()[0].path; clearSelection(); clearCache(selection); diff --git a/public/assets/pages/filespage/ctrl_upload.css b/public/assets/pages/filespage/ctrl_upload.css index 1968988c..eb97aaf7 100644 --- a/public/assets/pages/filespage/ctrl_upload.css +++ b/public/assets/pages/filespage/ctrl_upload.css @@ -17,7 +17,7 @@ padding: 20px 20px 5px 20px; margin: 0 0 5px 0; font-size: 1.2em; - font-weight: 100; + font-weight: normal; } .component_upload h2 .percent { color: var(--emphasis-primary); diff --git a/public/assets/pages/filespage/ctrl_upload.js b/public/assets/pages/filespage/ctrl_upload.js index e0914634..c8fa1b7d 100644 --- a/public/assets/pages/filespage/ctrl_upload.js +++ b/public/assets/pages/filespage/ctrl_upload.js @@ -5,23 +5,26 @@ import { loadCSS } from "../../helpers/loader.js"; import { qs } from "../../lib/dom.js"; import { AjaxError } from "../../lib/error.js"; import assert from "../../lib/assert.js"; +import { currentPath } from "./helper.js"; +import { mkdir, save } from "./model_virtual_layer.js"; import t from "../../locales/index.js"; +const workers$ = new rxjs.BehaviorSubject({ tasks: [], size: null }); + export default function(render) { const $page = createFragment(`
`); - const tasks$ = new rxjs.BehaviorSubject([]); if (!document.querySelector(`[is="component_upload_queue"]`)) { const $queue = createElement(`
`); document.body.appendChild($queue); - componentUploadQueue(createRender($queue), { tasks$ }); + componentUploadQueue(createRender($queue), { workers$ }); } - componentFilezone(createRender($page.children[0]), { tasks$ }); - componentUploadFAB(createRender($page.children[1]), { tasks$ }); + componentFilezone(createRender($page.children[0]), { workers$ }); + componentUploadFAB(createRender($page.children[1]), { workers$ }); render($page); } @@ -29,7 +32,7 @@ export function init() { return loadCSS(import.meta.url, "./ctrl_upload.css"); } -function componentUploadFAB(render, { tasks$ }) { +function componentUploadFAB(render, { workers$ }) { const $page = createElement(`
@@ -46,17 +49,18 @@ function componentUploadFAB(render, { tasks$ }) {
`); effect(rxjs.fromEvent(qs($page, `input[type="file"]`), "change").pipe( - rxjs.tap(async (e) => tasks$.next(await processFiles(e.target.files))) + rxjs.tap(async (e) => workers$.next(await processFiles(e.target.files))), )); render($page); } -function componentFilezone(render, { tasks$ }) { +function componentFilezone(render, { workers$ }) { const $target = document.body.querySelector(`[data-bind="filemanager-children"]`); $target.ondragenter = (e) => { e.preventDefault(); e.stopPropagation(); $target.classList.add("dropzone"); + e.dataTransfer.setData("type", "fileupload"); }; $target.ondragover = (e) => { e.preventDefault(); @@ -68,9 +72,13 @@ function componentFilezone(render, { tasks$ }) { e.preventDefault(); e.stopPropagation(); const loadID = setTimeout(() => render(createElement("
LOADING
")), 2000); - if (e.dataTransfer.items instanceof window.DataTransferItemList) tasks$.next(await processItems(e.dataTransfer.items)); - else if (e.dataTransfer.files instanceof window.FileList) tasks$.next(await processFiles(e.dataTransfer.files)); - else assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry); + if (e.dataTransfer.items instanceof window.DataTransferItemList) { + workers$.next(await processItems(e.dataTransfer.items)); + } else if (e.dataTransfer.files instanceof window.FileList) { + workers$.next(await processFiles(e.dataTransfer.files)); + } else { + assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry); + } $target.classList.remove("dropzone"); clearTimeout(loadID); render(createFragment("")); @@ -79,7 +87,7 @@ function componentFilezone(render, { tasks$ }) { const MAX_WORKERS = 4; -function componentUploadQueue(render, { tasks$ }) { +function componentUploadQueue(render, { workers$ }) { const $page = createElement(` `); - // feature1: close the queue and stop the upload - effect(onClick(qs($page, `img[alt="close"]`)).pipe( + // feature1: close the queue + onClick(qs($page, `img[alt="close"]`)).pipe( rxjs.mergeMap(() => animate($page, { time: 200, keyframes: slideYOut(50) })), rxjs.tap(() => $page.classList.add("hidden")), - )); + ).subscribe(); // feature2: setup the task queue in the dom - effect(tasks$.asObservable().pipe(rxjs.tap((tasks) => { + workers$.subscribe(({ tasks }) => { + console.log("TASKS SETUP DOM", tasks); if (tasks.length === 0) return; $page.classList.remove("hidden"); const $fragment = document.createDocumentFragment(); @@ -123,7 +132,7 @@ function componentUploadQueue(render, { tasks$ }) { $task.firstElementChild.nextElementSibling.textContent = t("Waiting"); } $content.appendChild($fragment); - }))); + }); // feature3: process tasks const $icon = createElement(`stop`) @@ -131,10 +140,14 @@ function componentUploadQueue(render, { tasks$ }) { const updateDOMTaskProgress = ($task, text) => $task.firstElementChild.nextElementSibling.textContent = text; const updateDOMTaskSpeed = ($task, text) => $task.firstElementChild.firstElementChild.nextElementSibling.textContent = formatSpeed(text); const updateDOMGlobalSpeed = function (workersSpeed) { + let last = 0; return (nworker, currentWorkerSpeed) => { workersSpeed[nworker] = currentWorkerSpeed; + if (new Date() - last <= 500) return; + last = new Date(); const speed = workersSpeed.reduce((acc, el) => acc + el, 0); - $page.firstElementChild.nextElementSibling.firstElementChild.textContent = formatSpeed(speed); + const $speed = $page.firstElementChild.nextElementSibling.firstElementChild; + $speed.textContent = formatSpeed(speed); }; }(new Array(MAX_WORKERS).fill(0)); const updateDOMGlobalTitle = ($page, text) => $page.firstElementChild.nextElementSibling.childNodes[0].textContent = text; @@ -202,7 +215,8 @@ function componentUploadQueue(render, { tasks$ }) { } }; const noFailureAllowed = (fn) => fn().catch(() => noFailureAllowed(fn)); - effect(tasks$.pipe(rxjs.tap(async (newTasks) => { + workers$.subscribe(async ({ tasks: newTasks }) => { + console.log("TASKS PROCESS", newTasks); tasks = tasks.concat(newTasks); // add new tasks to the pool while(true) { const nworker = reservations.indexOf(false); @@ -210,7 +224,7 @@ function componentUploadQueue(render, { tasks$ }) { reservations[nworker] = true; noFailureAllowed(processWorkerQueue.bind(this, nworker)).then(() => reservations[nworker] = false); } - }))); + }); } class IExecutor { @@ -219,7 +233,6 @@ class IExecutor { run() { throw new Error("NOT_IMPLEMENTED"); } } -const blob = new Blob(new Array(2 * 1024 * 1024).fill('a'), { type: "text/plain" }); function workerImplFile({ error, progress, speed }) { return new class Worker extends IExecutor { constructor() { @@ -232,16 +245,12 @@ function workerImplFile({ error, progress, speed }) { this.xhr.abort(); } - run({ stream }) { + async run({ entry, path, virtual }) { return new Promise((done, err) => { - console.log("EXECUTE", stream) this.xhr = new XMLHttpRequest(); - this.xhr.open("POST", "http://localhost:8334/api/files/cat?path=" + encodeURIComponent("/filestashtest/test/dummy.txt")); + this.xhr.open("POST", "api/files/cat?path=" + encodeURIComponent(path)); this.xhr.withCredentials = true; this.xhr.setRequestHeader("X-Requested-With", "XmlHttpRequest"); - this.xhr.onerror = function(e) { - err(new AjaxError("failed", e, "FAILED")); - }; this.xhr.upload.onabort = () => { err(new AjaxError("aborted", null, "ABORTED")); error(new AjaxError("aborted", null, "ABORTED")); @@ -271,17 +280,25 @@ function workerImplFile({ error, progress, speed }) { this.prevProgress.shift(); } }; - // this.xhr.onreadystatechange = () => console.log(this.xhr.readyState); this.xhr.onload = () => { progress(100); + virtual.afterSuccess(); done(); }; - this.xhr.send(stream); + this.xhr.onerror = function(e) { + err(new AjaxError("failed", e, "FAILED")); + vitual.afterError(); + }; + entry.file( + (file) => this.xhr.send(file), + (err) => this.xhr.onerror(err), + ); }); } } } + function workerImplDirectory({ error, progress }) { return new class Worker extends IExecutor { constructor() { @@ -293,10 +310,10 @@ function workerImplDirectory({ error, progress }) { this.xhr.abort(); } - run() { + run({ virtual, path }) { return new Promise((done, err) => { this.xhr = new XMLHttpRequest(); - this.xhr.open("POST", "http://localhost:8334/api/files/mkdir?path=" + encodeURIComponent("/filestashtest/test/dummy.txt")); + this.xhr.open("POST", "api/files/mkdir?path=" + encodeURIComponent(path)); this.xhr.withCredentials = true; this.xhr.setRequestHeader("X-Requested-With", "XmlHttpRequest"); this.xhr.onerror = function(e) { @@ -316,13 +333,14 @@ function workerImplDirectory({ error, progress }) { err(new AjaxError("aborted", null, "ABORTED")); error(new AjaxError("aborted", null, "ABORTED")); clearInterval(id); + virtual.afterError(); }; this.xhr.onload = () => { clearInterval(id); progress(100); + virtual.afterSuccess(); setTimeout(() => done(), 500); }; - console.log(stream) this.xhr.send(null); }); @@ -330,7 +348,7 @@ function workerImplDirectory({ error, progress }) { } } -async function processFiles(filelist) { +async function processFiles(filelist) { // TODO const files = []; const detectFiletype = (file) => { // the 4096 is an heuristic I've observed and taken from: @@ -354,10 +372,10 @@ async function processFiles(filelist) { for (const currentFile of filelist) { const type = await detectFiletype(currentFile); const file = { type, date: currentFile.lastModified, name: currentFile.name, path: currentFile.name }; - if (type === "file") file.size = currentFile.size; - else if (type === "directory") file.path += "/"; - else assert.fail(`NOT_SUPPORTED type="${type}"`, type); - file.stream = currentFile // TODO: put a file object in there + if (type === "directory") file.path += "/"; + else if (type === "file") { + fs.push({ type: "file", path, exec: workerImplFile, entry: currentFile }); + } else assert.fail(`NOT_SUPPORTED type="${type}"`, type); file.exec = workerImplFile.bind(file); files.push(file); } @@ -366,26 +384,42 @@ async function processFiles(filelist) { async function processItems(itemList) { const bfs = async (queue) => { - const fs = []; - let path = "" + const tasks = []; + let size = 0; + let path = ""; + const basepath = currentPath(); while (queue.length > 0) { const entry = queue.shift(); - const path = entry.fullPath.substring(1); + const path = basepath + entry.fullPath.substring(1); + let task = null; if (entry === null) continue; - else if (entry.isFile) { - const file = await new Promise((done) => entry.file((file) => done(file))); - fs.push({ type: "file", path, exec: workerImplFile, stream: file }); - continue; + if (entry.isFile) { + const entrySize = await new Promise((done) => entry.getMetadata(({ size }) => done(size))); + task = { + type: "file", entry, + path, + exec: workerImplFile, + virtual: save(path, entrySize), + }; + size += entrySize; } else if (entry.isDirectory) { - fs.push({ type: "directory", path: path + "/", exec: workerImplDirectory }); + task = { + type: "directory", + path: path + "/", + exec: workerImplDirectory, + virtual: mkdir(path), + }; + size += 5000; // that's to calculate the remaining time for an upload, aka made up size is ok queue = queue.concat(await new Promise((done) => { - entry.createReader().readEntries(done) + entry.createReader().readEntries(done); })); - continue; + } else { + assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry); } - assert.fail("NOT_IMPLEMENTED - unknown entry type in ctrl_upload.js", entry); + task.virtual.before(); + tasks.push(task); } - return fs; + return { tasks, size: 1000 }; } const entries = []; for (const item of itemList) entries.push(item.webkitGetAsEntry()); diff --git a/public/assets/pages/filespage/model_files.js b/public/assets/pages/filespage/model_files.js index 3b2080c5..3ce6619b 100644 --- a/public/assets/pages/filespage/model_files.js +++ b/public/assets/pages/filespage/model_files.js @@ -4,10 +4,7 @@ import notification from "../../components/notification.js"; import { setPermissions } from "./model_acl.js"; import fscache from "./cache.js"; -import { - rm as cacheRm, mv as cacheMv, save as cacheSave, - touch as cacheTouch, mkdir as cacheMkdir, ls as middlewareLs, -} from "./cache_transcient.js"; +import { ls as middlewareLs } from "./model_virtual_layer.js"; /* * The naive approach would be to make an API call and refresh the screen after an action @@ -22,55 +19,41 @@ import { * 3. the new file is being persisted in the screen if the API call is a success */ -const errorNotification = rxjs.catchError((err) => { +const withNotification = rxjs.catchError((err) => { notification.error(err); throw err; }); -export function touch(path) { - const ajax$ = ajax({ - url: `/api/files/touch?path=${encodeURIComponent(path)}`, - method: "POST", - responseType: "json", - }).pipe(errorNotification); - return cacheTouch(ajax$, path); -} +export const touch = (path) => ajax({ + url: `api/files/touch?path=${encodeURIComponent(path)}`, + method: "POST", + responseType: "json", +}).pipe(withNotification); -export function mkdir(path) { - const ajax$ = ajax({ - url: `/api/files/mkdir?path=${encodeURIComponent(path)}`, - method: "POST", - responseType: "json", - }).pipe(errorNotification); - return cacheMkdir(ajax$, path); -} +export const mkdir = (path) => ajax({ + url: `api/files/mkdir?path=${encodeURIComponent(path)}`, + method: "POST", + responseType: "json", +}).pipe(withNotification); -export function rm(...paths) { - const ajax$ = rxjs.forkJoin(paths.map((path) => ajax({ - url: `/api/files/rm?path=${encodeURIComponent(path)}`, - method: "POST", - responseType: "json", - }).pipe(errorNotification))); - return cacheRm(ajax$, ...paths); -} +export const rm = (...paths) => rxjs.forkJoin(paths.map((path) => ajax({ + url: `api/files/rm?path=${encodeURIComponent(path)}`, + method: "POST", + responseType: "json", +}).pipe(withNotification))); -export function mv(from, to) { - const ajax$ = ajax({ - url: `/api/files/mv?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, - method: "POST", - responseType: "json", - }).pipe(errorNotification); - return cacheMv(ajax$, from, to); -} +export const mv = (from, to) => ajax({ + url: `api/files/mv?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, + method: "POST", + responseType: "json", +}).pipe(withNotification); -export function save(path) { // TODO - return rxjs.of(null).pipe(rxjs.delay(1000)); -} +export const save = (path) => rxjs.of(null).pipe(rxjs.delay(1000)); -export function ls(path) { +export const ls = (path) => { const lsFromCache = (path) => rxjs.from(fscache().get(path)); const lsFromHttp = (path) => ajax({ - url: `/api/files/ls?path=${encodeURIComponent(path)}`, + url: `api/files/ls?path=${encodeURIComponent(path)}`, method: "GET", responseType: "json", }).pipe( @@ -114,14 +97,14 @@ export function ls(path) { rxjs.tap(({ permissions }) => setPermissions(path, permissions)), middlewareLs(path), ); -} +}; -export function search(term) { +export const search = (term) => { const path = location.pathname.replace(new RegExp("^/files/"), "/"); return ajax({ - url: `/api/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(term)}`, + url: `api/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(term)}`, responseType: "json" }).pipe(rxjs.map(({ responseJSON }) => ({ files: responseJSON.results, }))); -} +}; diff --git a/public/assets/pages/filespage/model_virtual_layer.js b/public/assets/pages/filespage/model_virtual_layer.js new file mode 100644 index 00000000..69219cc0 --- /dev/null +++ b/public/assets/pages/filespage/model_virtual_layer.js @@ -0,0 +1,402 @@ +import { onDestroy } from "../../lib/skeleton/index.js"; +import rxjs from "../../lib/rx.js"; +import fscache from "./cache.js"; +import { extractPath, isDir } from "./helper.js"; + +/* + * The virtual files is used to rerender the list of files in a particular location. That's used + * when we want to update the dom when doing either of a touch, mkdir, rm, mv, ... + * + * |---------------| |---------------| + * | LS | ---> | Virtual Layer | ---> Observable + * |---------------| |---------------| + * + * It is split onto 2 parts: + * - the virtualFiles$: which are things we want to display in addition to what is currently + * visible on the screen + * - the mutationFiles$: which are things already on the screen which we need to mutate. For + * example when we want a particular file to show a loading spinner, ... + */ + +const virtualFiles$ = new rxjs.BehaviorSubject({ + // "/tmp/": [], + // "/home/": [{ name: "test", type: "directory" }] +}); +const mutationFiles$ = new rxjs.BehaviorSubject({ + // "/home/": [{ name: "test", fn: (file) => file, ...] +}); + +class IVirtualLayer { + constructor() {} + before() { throw new Error("NOT_IMPLEMENTED"); } + async afterSuccess() { throw new Error("NOT_IMPLEMENTED"); } + async afterError() { throw new Error("NOT_IMPLEMENTED"); } +} + +export function withVirtualLayer(ajax$, mutate) { + mutate.before(); + return ajax$.pipe( + rxjs.tap((resp) => mutate.afterSuccess(resp)), + rxjs.catchError(mutate.afterError), + ); +}; + +export function touch(path) { + const [basepath, filename] = extractPath(path); + const file = { + name: filename, + type: "file", + size: 0, + time: new Date().getTime(), + }; + + return new class TouchVL extends IVirtualLayer { + constructor() { super(); } + + before() { + stateAdd(virtualFiles$, basepath, { + ...file, + loading: true, + }); + } + + async afterSuccess() { + removeLoading(virtualFiles$, basepath, filename); + onDestroy(() => statePop(virtualFiles$, basepath, filename)); + await fscache().update(basepath, ({ files, ...rest }) => ({ + files: files.concat([file]), + ...rest, + })); + } + + async afterError(err, caught) { + statePop(virtualFiles$, basepath, filename); + return rxjs.of(fscache().remove(basepath)).pipe( + rxjs.mergeMap(() => rxjs.EMPTY), + ); + } + } +} + +export function mkdir(path) { + const [basepath, dirname] = extractPath(path); + const file = { + name: dirname, + type: "directory", + size: 0, + time: new Date().getTime(), + }; + + return new class MkdirVL extends IVirtualLayer { + constructor() { super(); } + + before() { + stateAdd(virtualFiles$, basepath, { + ...file, + loading: true, + }); + } + + async afterSuccess() { + removeLoading(virtualFiles$, basepath, dirname); + onDestroy(() => statePop(virtualFiles$, basepath, dirname)); + await fscache().update(basepath, ({ files, ...rest }) => ({ + files: files.concat([file]), + ...rest, + })); + } + + async afterError(err, caught) { + statePop(virtualFiles$, basepath, dirname); + return rxjs.of(fscache().remove(basepath)).pipe( + rxjs.mergeMap(() => rxjs.EMPTY), + ); + } + } +} + +export function save(path, size) { + const [basepath, filename] = extractPath(path); + const file = { + name: filename, + type: "file", + size, + time: new Date().getTime(), + }; + + return new class SaveVL extends IVirtualLayer { + constructor() { super(); } + + before() { + stateAdd(virtualFiles$, basepath, { + ...file, + loading: true, + }); + } + + async afterSuccess() { + removeLoading(virtualFiles$, basepath, filename); + onDestroy(() => statePop(virtualFiles$, basepath, filename)); + await fscache().update(basepath, ({ files, ...rest }) => ({ + files: files.concat([file]), + ...rest, + })); + } + + async afterError() { + statePop(virtualFiles$, basepath, dirname); + return rxjs.EMPTY; + } + } +} + +export function rm(...paths) { + if (paths.length === 0) return rxjs.of(null); + const arr = new Array(paths.length * 2); + let basepath = null; + for (let i=0;i { + for (let i=0;i { + for (let i=0;i statePop(mutationFiles$, basepath, basepath)); + await Promise.all(paths.map((path) => fscache().remove(path, false))); + await fscache().update(basepath, ({ files, ...rest }) => ({ + files: files.filter(({ name }) => { + for (let i=0;i { + for (let i=0;i { + if (file.name === fromName) { + file.loading = true; + file.name = toName; + type = file.type; + } + return file; + }, + }); + } + _beforeDifferentPath() { + stateAdd(mutationFiles$, fromBasepath, { + name: fromName, + fn: (file) => { + if (file.name === fromName) { + file.loading = true; + file.last = true; + type = file.type; + } + return file; + }, + }); + stateAdd(virtualFiles$, toBasepath, { + name: toName, + loading: true, + type, + }); + } + + async afterSuccess() { + fscache().remove(fromPath, false); + if (fromBasepath === toBasepath) await this._afterSuccessSamePath(); + else await this._afterSuccessDifferentPath(); + } + async _afterSuccessSamePath() { + stateAdd(mutationFiles$, fromBasepath, { + name: fromName, + fn: (file) => { + if (file.name === toName) delete file.loading; + return file; + }, + }); + await fscache().update(fromBasepath, ({ files, ...rest }) => { + return { + files: files.map((file) => { + if (file.name === fromName) { + file.name = toName; + } + return file; + }), + ...rest, + }; + }); + } + async _afterSuccessDifferentPath() { + stateAdd(mutationFiles$, fromBasepath, { + name: fromName, + fn: (file) => { + if (file.name === fromName) return null; + return file; + }, + }); + onDestroy(() => statePop(mutationFiles$, fromBasepath, fromName)); + statePop(virtualFiles$, toBasepath, toName); + await fscache().update(fromBasepath, ({ files, ...rest }) => ({ + files: files.filter((file) => file.name === fromName ? false : true), + ...rest, + })) + await fscache().update(toBasepath, ({ files, ...rest }) => ({ + files: files.concat([{ + name: fromName, + time: new Date().getTime(), + type, + }]), + ...rest, + })); + if (isDir(fromPath)) await fscache.remove(fromPath); + } + + async afterError() { + statePop(mutationFiles$, fromBasepath, fromName); + if (fromBasepath !== toBasepath) { + statePop(virtualFiles$, toBasepath, toName); + } + return rxjs.EMPTY; + } + } +} + +export function ls(path) { + return rxjs.pipe( + // case1: virtual files = additional files we want to see displayed in the UI + rxjs.switchMap(({ files, ...res }) => virtualFiles$.pipe(rxjs.mergeMap((virtualFiles) => { + const shouldContinue = !!(virtualFiles[path] && virtualFiles[path].length > 0); + if (!shouldContinue) return rxjs.of({ ...res, files }); + return rxjs.of({ + ...res, + files: files.concat(virtualFiles[path]), + }); + }))), + // case2: file mutation = update a file state, typically to add a loading state to an + // file or remove it entirely + rxjs.switchMap(({ files, ...res }) => mutationFiles$.pipe(rxjs.mergeMap((fns) => { + const shouldContinue = !!(fns[path] && fns[path].length > 0); + if (!shouldContinue) return rxjs.of({ ...res, files }); + for (let i=files.length-1; i>=0; i--) { + for (let j=0; j name !== filename) + if (newArr.length === 0) { + const newState = { ...behavior.value }; + delete newState[path]; + behavior.next(newState); + return; + } + behavior.next({ + ...behavior.value, + [path]: newArr, + }); +} + +function removeLoading(behavior, path, filename) { + const arr = behavior.value[path]; + if (!arr) return; + virtualFiles$.next({ + ...virtualFiles$.value, + [path]: arr.map((file) => { + if (file.name === filename) delete file.loading; + return file; + }), + }); +}