Files
filestash/public/assets/pages/filespage/model_virtual_layer.js
2025-08-19 14:40:41 +10:00

510 lines
16 KiB
JavaScript

import { onDestroy } from "../../lib/skeleton/index.js";
import rxjs from "../../lib/rx.js";
import fscache from "./cache.js";
import { hooks } from "./model_files.js";
import { extractPath, isDir, currentPath } 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 {
before() { throw new Error("NOT_IMPLEMENTED"); }
async afterSuccess() { throw new Error("NOT_IMPLEMENTED"); }
async afterError() { return rxjs.EMPTY; }
}
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 {
/**
* @override
*/
before() {
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
}
/**
* @override
*/
async afterSuccess() {
removeLoading(virtualFiles$, basepath, filename);
onDestroy(() => statePop(virtualFiles$, basepath, filename));
await fscache().update(basepath, ({ files = [], ...rest }) => ({
files: files.concat([file]),
...rest,
}));
hooks.mutation.emit({ op: "touch", path: basepath });
}
/**
* @override
*/
async afterError() {
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 {
/**
* @override
*/
before() {
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
statePop(mutationFiles$, basepath, dirname); // case: rm followed by mkdir
}
/**
* @override
*/
async afterSuccess() {
if (basepath === currentPath()) removeLoading(virtualFiles$, basepath, dirname);
else onDestroy(() => removeLoading(virtualFiles$, basepath, dirname));
onDestroy(() => statePop(virtualFiles$, basepath, dirname));
await fscache().update(basepath, ({ files = [], ...rest }) => ({
files: files.concat([file]),
...rest,
}));
hooks.mutation.emit({ op: "mkdir", path: basepath });
}
/**
* @override
*/
async afterError() {
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 {
/**
* @override
*/
before() {
stateAdd(virtualFiles$, basepath, {
...file,
loading: true,
});
statePop(mutationFiles$, basepath, filename); // eg: rm followed by save
}
/**
* @override
*/
async afterSuccess() {
if (basepath === currentPath()) removeLoading(virtualFiles$, basepath, filename);
else onDestroy(() => removeLoading(virtualFiles$, basepath, filename));
onDestroy(() => statePop(virtualFiles$, basepath, filename));
await fscache().update(basepath, ({ files = [], ...rest }) => ({
files: files.concat([file]),
...rest,
}));
hooks.mutation.emit({ op: "save", path: basepath });
}
/**
* @override
*/
async afterError() {
statePop(virtualFiles$, basepath, filename);
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<paths.length; i++) {
[arr[2*i], arr[2*i+1]] = extractPath(paths[i]);
if (i === 0) basepath = arr[2*i];
else if (basepath !== arr[2*i]) throw new Error("NOT_IMPLEMENTED");
}
return new class RmVL extends IVirtualLayer {
/**
* @override
*/
before() {
for (let i=0; i<arr.length; i+=2) {
stateAdd(mutationFiles$, arr[i], {
name: arr[i+1],
fn: (file) => {
if (file.name === arr[i+1]) {
file.loading = true;
file.last = true;
}
return file;
},
});
statePop(virtualFiles$, arr[i], arr[i+1]); // eg: touch followed by rm
}
}
/**
* @override
*/
async afterSuccess() {
for (let i=0; i<arr.length; i+=2) {
stateAdd(mutationFiles$, arr[i], {
name: arr[i+1],
fn: (file) => {
for (let i=0; i<arr.length; i+=2) {
if (file.name === arr[i+1]) return null;
}
return file;
},
});
}
onDestroy(() => {
for (let i=0; i<arr.length; i+=2) {
statePop(mutationFiles$, arr[i], arr[i+1]);
}
});
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<arr.length; i+=2) {
if (name === arr[i+1]) {
return false;
}
}
return true;
}),
...rest,
}));
if (arr.length > 0) hooks.mutation.emit({ op: "rm", path: arr[0] });
}
/**
* @override
*/
async afterError() {
for (let i=0; i<arr.length; i+=2) {
stateAdd(mutationFiles$, arr[i], {
name: arr[i+1],
fn: (file) => {
if (file.name === arr[i+1]) {
file = {
...file,
loading: false,
last: false
};
}
return file;
},
});
}
return rxjs.EMPTY;
}
}();
}
export function mv(fromPath, toPath) {
const [fromBasepath, fromName] = extractPath(fromPath);
const [toBasepath, toName] = extractPath(toPath);
let type = null;
return new class MvVL extends IVirtualLayer {
/**
* @override
*/
before() {
if (fromBasepath === toBasepath) this._beforeSamePath();
else this._beforeDifferentPath();
}
_beforeSamePath() {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) {
type = file.type;
return {
...file,
name: toName,
loading: true,
};
}
return file;
},
});
}
_beforeDifferentPath() {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) {
type = file.type;
return {
...file,
loading: true,
last: true,
};
}
return file;
},
});
stateAdd(virtualFiles$, toBasepath, {
name: toName,
loading: true,
type,
});
}
/**
* @override
*/
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) {
file = { ...file, loading: false };
}
return file;
},
});
await fscache().update(fromBasepath, ({ files = [], ...rest }) => {
return {
files: files.map((file) => {
if (file.name === fromName) {
file.name = toName;
}
return file;
}),
...rest,
};
});
hooks.mutation.emit({ op: "mv", path: fromBasepath });
}
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),
...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);
hooks.mutation.emit({ op: "mv", path: fromBasepath });
hooks.mutation.emit({ op: "mv", path: toBasepath });
}
/**
* @override
*/
async afterError() {
if (fromBasepath === toBasepath) stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === toName) return {
...file,
name: fromName,
loading: false,
};
return file;
},
}); else {
stateAdd(mutationFiles$, fromBasepath, {
name: fromName,
fn: (file) => {
if (file.name === fromName) return {
...file,
loading: false,
last: false,
};
return file;
},
});
statePop(virtualFiles$, toBasepath, toName);
}
statePop(mutationFiles$, fromBasepath, fromName);
return rxjs.EMPTY;
}
}();
}
export function ls(path) {
return rxjs.pipe(
// case1: 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.map((all) => all[path]),
rxjs.mergeMap((fns) => {
const shouldContinue = !!(fns && fns.length > 0);
if (!shouldContinue) return rxjs.of({ ...res, files });
for (let i=files.length-1; i>=0; i--) {
for (let j=0; j<fns.length; j++) {
files[i] = fns[j].fn(files[i]);
if (!files[i]) {
files.splice(i, 1);
break;
}
}
}
return rxjs.of({ ...res, files });
}),
)),
// case2: virtual files = additional files we want to see displayed in the UI
rxjs.switchMap(({ files, ...res }) => virtualFiles$.pipe(
rxjs.map((all) => all[path] || []),
rxjs.distinctUntilChanged((prev, curr) => { // we only want to get notified of changes within "path"
if (prev.length !== curr.length) return false;
for (let i=0; i<prev.length; i++) {
if (prev[i].name !== curr[i].name) return false;
else if (prev[i].type !== curr[i].type) return false;
else if (prev[i].loading !== curr[i].loading) return false;
}
return true;
}),
rxjs.mergeMap((virtualFiles) => {
if (virtualFiles.length === 0) return rxjs.of({ ...res, files });
return rxjs.of({
...res,
files: files.concat(virtualFiles),
});
}),
)),
);
}
function stateAdd(behavior, path, obj) {
let arr = behavior.value[path];
if (!arr) arr = [];
let alreadyKnown = false;
for (let i=0; i<arr.length; i++) {
if (arr[i].name === obj.name) {
alreadyKnown = true;
arr[i] = obj;
break;
}
}
if (!alreadyKnown) {
arr = arr.concat([obj]);
}
behavior.next({
...behavior.value,
[path]: arr,
});
}
function statePop(behavior, path, filename) {
const arr = behavior.value[path];
if (!arr) return;
const newArr = arr.filter(({ name }) => 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) {
return { ...file, loading: false };
}
return file;
}),
});
}