Files
cloudron-box/dashboard/src/components/FolderView.vue
2025-03-28 11:14:49 +01:00

617 lines
20 KiB
Vue

<script>
import { marked } from 'marked';
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from 'pankow';
import { sanitize, sleep } from 'pankow/utils';
import { API_ORIGIN, ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const BASE_URL = import.meta.env.BASE_URL || '/';
const beforeUnloadListener = (event) => {
event.preventDefault();
return window.confirm('File operation still in progress. Really close?');
};
export default {
name: 'FolderView',
components: {
BottomBar,
Button,
ButtonGroup,
Dialog,
DirectoryView,
FileUploader,
InputDialog,
MainLayout,
Notification,
Breadcrumb,
PreviewPanel,
Spinner,
TopBar
},
data() {
return {
busy: true,
fallbackIcon: `${BASE_URL}mime-types/none.svg`,
cwd: '/',
busyRefresh: false,
busyRestart: false,
fatalError: false,
footerContent: '',
activeItem: null,
activeDirectoryItem: {},
items: [],
selectedItems: [],
accessToken: localStorage.token,
title: 'Cloudron',
appLink: '',
resourceType: '',
resourceId: '',
visible: true,
uploadRequest: null,
breadcrumbHomeItem: {
label: '/app/data/',
action: () => {
this.cwd = '/';
}
},
ownersModel: [],
// contextMenuModel will have activeItem attached if any command() is called
createMenuModel: [{
label: this.$t('filemanager.toolbar.newFile'),
icon: 'fa-solid fa-file-circle-plus',
action: this.onNewFile
}, {
label: this.$t('filemanager.toolbar.newFolder'),
icon: 'fa-solid fa-folder-plus',
action: this.onNewFolder
}],
uploadMenuModel: [{
label: this.$t('filemanager.toolbar.uploadFile'),
icon: 'fa-solid fa-file-arrow-up',
action: this.onUploadFile
}, {
label: this.$t('filemanager.toolbar.newFolder'),
icon: 'fa-regular fa-folder-open',
action: this.onUploadFolder
}]
};
},
computed: {
breadcrumbItems() {
const parts = this.cwd.split('/').filter((p) => !!p.trim())
const crumbs = [];
parts.forEach((p, i) => {
crumbs.push({
label: p,
action: () => {
this.cwd = '/' + parts.slice(0, i+1).join('/');
}
});
});
return crumbs;
}
},
watch: {
cwd(newCwd, oldCwd) {
if (this.resourceType && this.resourceId) this.$router.push(`/home/${this.resourceType}/${this.resourceId}${this.cwd}`);
this.loadCwd();
}
},
async mounted() {
this.busy = true;
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
if (type === 'app') {
let error, result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} else if (type === 'volume') {
let error, result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/volumes/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.title = result.body.name;
} else {
return this.onFatalError(`Unsupported type ${type}`);
}
try {
const result = await fetcher.get(`${API_ORIGIN}/api/v1/dashboard/config`, { access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
}
window.document.title = `File Manager - ${this.title}`;
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') return this.onFatalError(`Unknown type ${toParams.type}`);
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
});
},
methods: {
onFatalError(errorMessage) {
this.fatalError = errorMessage;
this.$refs.fatalErrorDialog.open();
},
onCancelUpload() {
if (!this.uploadRequest || !this.uploadRequest.xhr) return;
this.uploadRequest.xhr.abort();
},
// generic dialog focus handler
onDialogShow(focusElementId) {
setTimeout(() => document.getElementById(focusElementId).focus(), 0);
},
async onNewFile() {
const newFileName = await this.$refs.inputDialog.prompt({
message: this.$t('filemanager.newFileDialog.title'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('filemanager.newFileDialog.create'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!newFileName) return;
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, newFileName));
await this.loadCwd();
},
async onNewFolder() {
const newFolderName = await this.$refs.inputDialog.prompt({
message: this.$t('filemanager.newDirectoryDialog.title'),
value: '',
confirmStyle: 'success',
confirmLabel: this.$t('filemanager.newFileDialog.create'),
rejectLabel: this.$t('main.dialog.cancel'),
modal: false
});
if (!newFolderName) return;
await this.directoryModel.newFolder(this.directoryModel.buildFilePath(this.cwd, newFolderName));
await this.loadCwd();
},
onUploadFile() {
this.$refs.fileUploader.onUploadFile(this.cwd);
},
onUploadFolder() {
this.$refs.fileUploader.onUploadFolder(this.cwd);
},
onUploadFinished() {
this.loadCwd();
},
onAppChange(event) {
this.$router.push(`/home/${event.value.type}/${event.value.id}`);
this.cwd = '/';
this.loadResource(event.value);
},
onSelectionChanged(items) {
this.activeItem = items[0] || null;
this.selectedItems = items;
},
onActivateBreadcrumb(path) {
this.cwd = sanitize(path);
},
async onRefresh() {
this.busyRefresh = true;
await this.loadCwd();
setTimeout(() => { this.busyRefresh = false; }, 500);
},
// either dataTransfer (external drop) or files (internal drag)
async onDrop(targetFolder, dataTransfer, files) {
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
// if dataTransfer is set, we have a file/folder drop from outside
if (dataTransfer) {
async function getFile(entry) {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
async function readEntries(dirReader) {
return new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
}
const fileList = [];
async function traverseFileTree(item) {
if (item.isFile) {
fileList.push(await getFile(item));
} else if (item.isDirectory) {
// Get folder contents
const dirReader = item.createReader();
const entries = await readEntries(dirReader);
for (let i in entries) {
await traverseFileTree(entries[i], item.name);
}
} else {
console.log('Skipping uknown file type', item);
}
}
// collect all files to upload
for (const item of dataTransfer.items) {
const entry = item.webkitGetAsEntry();
if (entry.isFile) {
fileList.push(await getFile(entry));
} else if (entry.isDirectory) {
await traverseFileTree(entry, sanitize(`${this.cwd}/${targetFolder}`));
}
}
this.$refs.fileUploader.addFiles(fileList, sanitize(`${this.cwd}/${targetFolder}`));
} else {
if (!files.length) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.open();
// check ctrl for cut/copy
await this.directoryModel.paste(fullTargetFolder, 'cut', files);
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.close();
}
},
onItemActivated(item) {
if (!item) return;
if (item.mimeType === 'inode/symlink') return;
if (item.type === 'directory') this.cwd = sanitize(this.cwd + '/' + item.name);
else this.$router.push(`/viewer/${this.resourceType}/${this.resourceId}${sanitize(this.cwd + '/' + item.name)}`);
},
async deleteHandler(files) {
if (!files) return;
const confirmed = await this.$refs.inputDialog.confirm({
message: this.$t('filemanager.removeDialog.reallyDelete'),
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no'),
modal: false
});
if (!confirmed) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.deleteInProgressDialog.open();
for (let i in files) {
try {
await this.directoryModel.remove(this.directoryModel.buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
}
}
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.deleteInProgressDialog.close();
},
async renameHandler(file, newName) {
if (file.name === newName) return;
try {
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
await this.loadCwd();
} catch (e) {
if (e.status === 409) {
const confirmed = await this.$refs.inputDialog.confirm({
message: this.$t('filemanager.renameDialog.reallyOverwrite'),
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no')
});
if (!confirmed) return;
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName), true /* overwrite */);
await this.loadCwd();
}
else console.error(`Failed to rename ${file} to ${newName}`, e);
}
},
async changeOwnerHandler(files, newOwnerUid) {
if (!files) return;
for (let i in files) {
await this.directoryModel.chown(this.directoryModel.buildFilePath(this.cwd, files[i].name), newOwnerUid);
}
await this.loadCwd();
},
async pasteHandler(action, files, target) {
if (!files || !files.length) return;
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.open();
try {
await this.directoryModel.paste(targetPath, action, files);
} catch (e) {
window.pankow.notify({ type: 'danger', text: e, persistent: true })
}
this.clipboard = {};
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.close();
},
async downloadHandler(file) {
await this.directoryModel.download(this.directoryModel.buildFilePath(this.cwd, file.name));
},
async extractHandler(file) {
this.$refs.extractInProgressDialog.open();
await this.directoryModel.extract(this.directoryModel.buildFilePath(this.cwd, file.name));
await this.loadCwd();
this.$refs.extractInProgressDialog.close();
},
async uploadHandler(targetDir, file, progressHandler) {
this.uploadRequest = this.directoryModel.upload(targetDir, file, progressHandler);
try {
await this.uploadRequest;
} catch (e) {
console.log('Upload cancelled.', e);
}
this.uploadRequest = null;
await this.loadCwd();
},
async loadCwd() {
this.items = await this.directoryModel.listFiles(this.cwd);
const tmp = this.cwd.split('/').slice(1);
let name = '';
if (tmp.length >= 1 && tmp[tmp.length-1]) name = tmp[tmp.length-1];
this.activeDirectoryItem = {
id: name,
name: name,
type: 'directory',
mimeType: 'inode/directory',
icon: `${BASE_URL}mime-types/inode-directory.svg`
};
this.busy = false;
},
async onRestartApp() {
if (this.resourceType !== 'app') return;
const confirmed = await this.$refs.inputDialog.confirm({
message: this.$t('filemanager.toolbar.restartApp') + '?',
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no')
});
if (!confirmed) return;
this.busyRestart = true;
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.resourceId}/restart`, null, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.status);
return;
}
while(true) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${this.resourceId}`, { access_token: this.accessToken });
} catch (e) {
console.error('Failed to fetch app status.', e);
}
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
this.busyRestart = false;
}
}
};
</script>
<template>
<MainLayout>
<template #dialogs>
<Notification />
<Dialog ref="fatalErrorDialog" modal title="Error">
<p>{{ fatalError }}</p>
</Dialog>
<Dialog ref="extractInProgressDialog" modal :title="$t('filemanager.extractionInProgress')">
<div style="text-align: center;">
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog ref="pasteInProgressDialog" modal :title="$t('filemanager.pasteInProgress')">
<div style="text-align: center;">
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog ref="deleteInProgressDialog" modal :title="$t('filemanager.deleteInProgress')">
<div style="text-align: center;">
<Spinner style="margin: 10px; width: 50px; height: 50px"/>
</div>
</Dialog>
<InputDialog ref="inputDialog" />
</template>
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool/>
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
</template>
<template #right>
<ButtonGroup>
<Button icon="fa-solid fa-plus" :menu="createMenuModel">{{ $t('filemanager.toolbar.new') }}</Button>
<Button icon="fa-solid fa-upload" :menu="uploadMenuModel">{{ $t('filemanager.toolbar.upload') }}</Button>
</ButtonGroup>
<ButtonGroup>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<Button :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
</ButtonGroup>
</template>
</TopBar>
</template>
<template #body>
<div class="main-view">
<div class="main-view-col">
<DirectoryView
class="directory-view"
:busy="busy"
:show-owner="true"
:show-size="true"
:show-modified="true"
@selection-changed="onSelectionChanged"
@item-activated="onItemActivated"
:delete-handler="deleteHandler"
:rename-handler="renameHandler"
:change-owner-handler="changeOwnerHandler"
:paste-handler="pasteHandler"
:download-handler="downloadHandler"
:extract-handler="extractHandler"
:new-file-handler="onNewFile"
:new-folder-handler="onNewFolder"
:upload-file-handler="onUploadFile"
:upload-folder-handler="onUploadFolder"
:drop-handler="onDrop"
:items="items"
:owners-model="ownersModel"
:fallback-icon="fallbackIcon"
:tr="$t"
/>
</div>
<div class="main-view-col" style="max-width: 300px;">
<div class="side-bar-title">
<a v-show="appLink" :href="appLink" target="_blank" class="no-highlight">{{ title }}</a>
<span v-show="!appLink">{{ title }}</span>
</div>
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
</div>
</div>
</template>
<template #footer>
<FileUploader
ref="fileUploader"
:upload-handler="uploadHandler"
:cancel-handler="onCancelUpload"
@finished="onUploadFinished"
:tr="$t"
/>
<BottomBar>
<div v-html="footerContent" class="bottom-bar-content"></div>
</BottomBar>
</template>
</MainLayout>
</template>
<style scoped>
.main-view {
flex-grow: 1;
overflow: hidden;
height: 100%;
display: flex;
padding: 0 10px;
}
.side-bar-title {
text-align: center;
font-size: 20px;
margin-bottom: 20px;
}
.main-view-col {
flex-grow: 1;
}
.directory-view {
background-color: var(--pankow-color-background);
}
.no-highlight {
color: var(--pankow-color-text);
}
</style>
<style>
.bottom-bar-content > p {
margin: 0;
}
</style>