2021-02-11 00:57:36 +08:00

619 lines
29 KiB
Vue

<style>
.my-editor {
background: #2d2d2d;
color: #ccc;
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
font-size: 14px;
line-height: 1.5;
padding: 5px;
}
.prism-editor__textarea:focus {
outline: none;
}
.config-editor-overlay div.v-card {
position: relative;
}
.config-editor-overlay div.v-card header {
position: sticky;
top: 0;
width: 100%;
z-index: 1;
}
</style>
<template>
<v-row>
<v-col>
<v-card>
<v-card-title>
{{ $t('Setting.ConfigFiles') }}
<v-spacer class="d-none d-sm-block"></v-spacer>
<input type="file" ref="fileUpload" style="display: none" multiple @change="uploadFile" />
<v-item-group class="v-btn-toggle my-5 my-sm-0 col-12 col-sm-auto px-0 py-0">
<v-btn v-if="currentPath !== '' && currentPath !== '/config_examples'" class="flex-grow-1" @click="uploadFileButton" :loading="loadings.includes['configFileUpload']"><v-icon>mdi-file-upload</v-icon></v-btn>
<v-btn v-if="currentPath !== '' && currentPath !== '/config_examples'" class="flex-grow-1" @click="createFile"><v-icon>mdi-file-plus</v-icon></v-btn>
<v-btn v-if="currentPath !== '' && currentPath !== '/config_examples'" class="flex-grow-1" @click="createFolder"><v-icon>mdi-folder-plus</v-icon></v-btn>
<v-btn class="flex-grow-1" @click="refreshFileList"><v-icon>mdi-refresh</v-icon></v-btn>
<v-menu :offset-y="true" title="Setup current list">
<template v-slot:activator="{ on, attrs }">
<v-btn class="flex-grow-1" v-bind="attrs" v-on="on"><v-icon class="">mdi-cog</v-icon></v-btn>
</template>
<v-list>
<v-list-item class="minHeight36">
<v-checkbox class="mt-0" hide-details v-model="showHiddenFiles" :label="$t('Setting.HiddenFiles')"></v-checkbox>
</v-list-item>
</v-list>
</v-menu>
</v-item-group>
</v-card-title>
<v-card-subtitle>{{ $t('Setting.CurrentPath') }} {{ this.currentPath === "" ? "/" : this.currentPath }}</v-card-subtitle>
<v-data-table
:items="files"
class="files-table"
:headers="headers"
:options="options"
:page.sync="currentPage"
:custom-sort="sortFiles"
:sort-by.sync="sortBy"
:sort-desc.sync="sortDesc"
:items-per-page.sync="countPerPage"
:footer-props="{
itemsPerPageText: $t('Setting.Files')
}"
mobile-breakpoint="0"
item-key="name">
<template #no-data>
<div class="text-center">{{ $t('Setting.Empty') }}</div>
</template>
<template slot="body.prepend" v-if="(currentPath !== '')">
<tr
class="file-list-cursor"
@click="clickRowGoBack">
<td class="pr-0 text-center" style="width: 32px;"><v-icon>mdi-folder-upload</v-icon></td>
<td class=" " colspan="8">..</td>
</tr>
</template>
<template #item="{ item }">
<tr
@contextmenu="showContextMenu($event, item)"
@click="clickRow(item)"
class="file-list-cursor"
:data-name="item.filename">
<td class="pr-0 text-center" style="width: 32px;">
<v-icon v-if="item.isDirectory">mdi-folder</v-icon>
<v-icon v-if="!item.isDirectory">mdi-file</v-icon>
</td>
<td class=" ">{{ item.filename }}</td>
<td class="text-no-wrap text-right">{{ item.isDirectory ? '--' : formatFilesize(item.size) }}</td>
<td class="text-right">{{ formatDate(item.modified) }}</td>
</tr>
</template>
</v-data-table>
</v-card>
<v-menu v-model="contextMenu.shown" :position-x="contextMenu.x" :position-y="contextMenu.y" absolute offset-y>
<v-list>
<v-list-item @click="clickRow(contextMenu.item)" v-if="!contextMenu.item.isDirectory">
<v-icon class="mr-1">mdi-file-document-edit-outline</v-icon> {{ $t('Setting.EditFile') }}
</v-list-item>
<v-list-item @click="downloadFile" v-if="!contextMenu.item.isDirectory">
<v-icon class="mr-1">mdi-cloud-download</v-icon> {{ $t('Setting.Download') }}
</v-list-item>
<v-list-item @click="renameFile(contextMenu.item)" v-if="!contextMenu.item.isDirectory && currentPath !== '/config_examples'">
<v-icon class="mr-1">mdi-rename-box</v-icon> {{ $t('Setting.Rename') }}
</v-list-item>
<v-list-item @click="removeFile" v-if="!contextMenu.item.isDirectory && currentPath !== '/config_examples'">
<v-icon class="mr-1">mdi-delete</v-icon> {{ $t('Setting.Delete') }}
</v-list-item>
<v-list-item @click="deleteDirectoryAction" v-if="contextMenu.item.isDirectory && currentPath !== '' && currentPath !== '/config_examples'">
<v-icon class="mr-1">mdi-delete</v-icon> {{ $t('Setting.Delete') }}
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="editor.showLoader" hide-overlay persistent width="300" >
<v-card color="primary" dark >
<v-card-text>
{{ $t('Setting.PleaseStandBy') }}
<v-progress-linear indeterminate color="white" class="mb-0" ></v-progress-linear>
</v-card-text>
</v-card>
</v-dialog>
<v-dialog v-model="editor.show" fullscreen hide-overlay transition="dialog-bottom-transition" content-class="config-editor-overlay">
<v-card d-flex>
<v-toolbar dark color="primary">
<v-btn icon dark @click="editor.show = false">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>{{ editor.item.filename }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn dark text href="https://www.klipper3d.org/Config_Reference.html" target="_blank" class="d-none d-md-flex"><v-icon small class="mr-1">mdi-help</v-icon>{{ $t('Setting.ConfigReference') }}</v-btn>
<v-divider white vertical v-if="currentPath !== '/config_examples'" class="d-none d-md-flex"></v-divider>
<v-btn dark text @click="saveFile(false)" v-if="currentPath !== '/config_examples'"><v-icon small class="mr-1">mdi-content-save</v-icon><span class="d-none d-sm-inline">{{ $t('Setting.Save') }}</span></v-btn>
<v-divider white vertical v-if="currentPath !== '/config_examples' && !['printing', 'paused'].includes(printer_state)" class="d-none d-sm-flex"></v-divider>
<v-btn dark text @click="saveFile(true)" v-if="currentPath !== '/config_examples' && !['printing', 'paused'].includes(printer_state)" class="d-none d-sm-flex"><v-icon small class="mr-1">mdi-restart</v-icon>{{ $t('Setting.SaveRestart') }}</v-btn>
</v-toolbar-items>
</v-toolbar>
<prism-editor class="my-editor" v-model="editor.sourcecode" :readonly="editor.readonly" :highlight="highlighter" line-numbers></prism-editor>
</v-card>
</v-dialog>
<v-dialog v-model="dialogRenameFile.show" max-width="400">
<v-card>
<v-card-title class="headline">{{ $t('Setting.RenameFile') }}</v-card-title>
<v-card-text>
<v-text-field :label="$t('Setting.Name')" required v-model="dialogRenameFile.newName"></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="" text @click="dialogRenameFile.show = false">{{ $t('Setting.Cancel') }}</v-btn>
<v-btn color="primary" text @click="renameFileAction">{{ $t('Setting.Rename') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="dialogCreateFile.show" max-width="400">
<v-card>
<v-card-title class="headline">{{ $t('Setting.CreateFile') }}</v-card-title>
<v-card-text>
<v-text-field :label="$t('Setting.Name')" required v-model="dialogCreateFile.name"></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="" text @click="dialogCreateFile.show = false">{{ $t('Setting.Cancel') }}</v-btn>
<v-btn color="primary" text @click="createFileAction">{{ $t('Setting.Create') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="dialogCreateFolder.show" max-width="400">
<v-card>
<v-card-title class="headline">{{ $t('Setting.CreateFolder') }}</v-card-title>
<v-card-text>
<v-text-field :label="$t('Setting.Name')" required v-model="dialogCreateFolder.name"></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="" text @click="dialogCreateFolder.show = false">{{ $t('Setting.Cancel') }}</v-btn>
<v-btn color="primary" text @click="createFolderAction">{{ $t('Setting.Create') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
:timeout="-1"
:value="true"
fixed
right
bottom
dark
v-model="uploadSnackbar.status"
>
<span v-if="uploadSnackbar.max > 1" class="mr-1">({{ uploadSnackbar.number }}/{{ uploadSnackbar.max }})</span><strong>{{ $t('Setting.Uploading') }} {{ uploadSnackbar.filename }}</strong><br />
{{ Math.round(uploadSnackbar.percent) }} % @ {{ formatFilesize(Math.round(uploadSnackbar.speed)) }}/s<br />
<v-progress-linear class="mt-2" :value="uploadSnackbar.percent"></v-progress-linear>
<template v-slot:action="{ attrs }">
<v-btn
color="red"
text
v-bind="attrs"
@click="cancelUpload"
style="min-width: auto;"
>
<v-icon class="0">mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</v-col>
</v-row>
</template>
<script>
import { mapState } from 'vuex'
import {findDirectory} from "@/plugins/helpers";
import { PrismEditor } from 'vue-prism-editor';
import 'vue-prism-editor/dist/prismeditor.min.css'; // import the styles somewhere
// import highlighting library (you can use any library you want just return html string)
import { highlight, languages } from 'prismjs/components/prism-core';
import 'prismjs/components/prism-editorconfig';
import 'prismjs/components/prism-ini';
import 'prismjs/themes/prism-okaidia.css';
import axios from "axios"; // import syntax highlighting styles
export default {
components: {
PrismEditor,
},
data: function() {
return {
sortBy: 'filename',
sortDesc: false,
selected: [],
headers: [
{ text: '', value: '', },
{ text: this.$t('Setting.Name'), value: 'filename', },
{ text: this.$t('Setting.Filesize'), value: 'size', align: 'right', },
{ text: this.$t('Setting.LastModified'), value: 'modified', align: 'right', },
],
options: {
},
currentPage: 1,
files: [],
currentPath: '/config',
editor: {
show: false,
showLoader: false,
readonly: false,
item: {
filename: "",
},
sourcecode: ''
},
contextMenu: {
shown: false,
isDirectory: false,
touchTimer: undefined,
x: 0,
y: 0,
item: {}
},
dialogRenameFile: {
show: false,
newName: "",
item: {}
},
dialogCreateFile: {
show: false,
name: "",
},
dialogCreateFolder: {
show: false,
name: "",
},
uploadSnackbar: {
status: false,
filename: "",
percent: 0,
speed: 0,
total: 0,
number: 0,
max: 0,
cancelTokenSource: "",
lastProgress: {
time: 0,
loaded: 0
}
}
}
},
computed: {
...mapState({
filetree: state => state.files.filetree,
hostname: state => state.socket.hostname,
port: state => state.socket.port,
loadings: state => state.socket.loadings,
printer_state: state => state.printer.print_stats.state,
}),
countPerPage: {
get: function() {
return this.$store.state.gui.settings.configfiles.countPerPage
},
set: function(newVal) {
return this.$store.dispatch("gui/setSettings", { settings: { configfiles: { countPerPage: newVal } } });
}
},
showHiddenFiles: {
get: function() {
return this.$store.state.gui.settings.configfiles.showHiddenFiles
},
set: function(newVal) {
return this.$store.dispatch("gui/setSettings", { settings: { configfiles: { showHiddenFiles: newVal } } })
}
},
},
created() {
this.loadPath();
},
methods: {
highlighter(code) {
return highlight(code, languages.ini); //returns html
},
refreshFileList: function() {
if (this.currentPath === "") {
this.$socket.sendObj('server.files.get_directory', { path: 'config' }, 'files/getDirectory');
this.$socket.sendObj('server.files.get_directory', { path: 'config_examples' }, 'files/getDirectory');
} else this.$socket.sendObj('server.files.get_directory', { path: this.currentPath.substring(1) }, 'files/getDirectory');
},
formatDate(date) {
let tmp2 = new Date(date);
return tmp2.toLocaleString().replace(',', '');
},
formatFilesize(fileSizeInBytes) {
let i = -1;
let byteUnits = [' kB', ' MB', ' GB', ' TB', 'PB', 'EB', 'ZB', 'YB'];
do {
fileSizeInBytes = fileSizeInBytes / 1024;
i++;
} while (fileSizeInBytes > 1024);
return Math.max(fileSizeInBytes, 0.1).toFixed(1) + byteUnits[i];
},
sortFiles(items, sortBy, sortDesc) {
sortBy = sortBy.length ? sortBy[0] : 'filename';
sortDesc = sortDesc[0];
if (items !== undefined) {
// Sort by index
items.sort(function(a, b) {
if (a[sortBy] === b[sortBy]) {
return 0;
}
if (a[sortBy] === null || a[sortBy] === undefined) {
return -1;
}
if (b[sortBy] === null || b[sortBy] === undefined) {
return 1;
}
if (a[sortBy].constructor === String && b[sortBy].constructor === String) {
return a[sortBy].localeCompare(b[sortBy], undefined, { sensivity: 'base' });
}
if (a[sortBy] instanceof Array && b[sortBy] instanceof Array) {
const reducedA = a[sortBy].length ? a.filament.reduce((a, b) => a + b) : 0;
const reducedB = b[sortBy].length ? b.filament.reduce((a, b) => a + b) : 0;
return reducedA - reducedB;
}
return a[sortBy] - b[sortBy];
});
// Deal with descending order
if (sortDesc) items.reverse();
// Then make sure directories come first
items.sort((a, b) => (a.isDirectory === b.isDirectory) ? 0 : (a.isDirectory ? -1 : 1));
}
return items;
},
loadPath: function() {
let dirArray = this.currentPath.substring(1).split("/");
this.files = findDirectory(this.filetree, dirArray);
if (dirArray.length === 1 && this.currentPath === "") {
this.files = this.files.filter(element => !["gcodes", "docs"].includes(element.filename))
}
if (!this.showHiddenFiles) {
this.files = this.files.filter(file => file.filename.substr(0, 1) !== ".");
}
},
clickRow: function(item) {
if (!item.isDirectory) {
this.editor.showLoader = true;
this.editor.sourcecode = "";
this.editor.item = item;
let url = '//'+this.hostname+':'+this.port+'/server/files'+this.currentPath+'/'+item.filename+'?time='+Date.now();
fetch(url, { cache: "no-cache" }).then(res => res.text()).then(file => {
this.editor.sourcecode = file;
this.editor.show = true;
this.editor.showLoader = false;
this.editor.readonly = false;
if (this.currentPath === '/config_examples') this.editor.readonly = true;
});
} else {
this.currentPath += "/"+item.filename;
this.currentPage = 1;
}
},
clickRowGoBack: function() {
this.currentPath = this.currentPath.substr(0, this.currentPath.lastIndexOf("/"));
},
saveFile: function(boolRestart = false) {
let file = new File([this.editor.sourcecode], this.editor.item.filename);
let formData = new FormData();
formData.append('file', file);
formData.append('root', 'config');
if(this.currentPath.length > 7) formData.append('path', this.currentPath.substring(8));
axios.post('//' + this.hostname + ':' + this.port + '/server/files/upload',
formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}
).then(() => {
this.$toast.success("File '"+this.editor.item.filename+"' successfully saved.");
this.editor.show = false;
this.editor.sourcecode = "";
if (boolRestart && this.editor.item.filename === "moonraker.conf") {
this.$socket.sendObj('machine.services.restart', { service: "moonraker" })
} else if (boolRestart) {
this.$store.commit('server/addEvent', { message: "FIRMWARE_RESTART", type: 'command' })
this.$socket.sendObj('printer.gcode.script', { script: "FIRMWARE_RESTART" })
}
}).catch(() => {
this.$toast.error("Error save "+this.editor.item.filename);
});
},
showContextMenu (e, item) {
e.preventDefault();
this.contextMenu.shown = true;
this.contextMenu.x = e.clientX;
this.contextMenu.y = e.clientY;
this.contextMenu.item = item;
this.$nextTick(() => {
this.contextMenu.shown = true
});
},
downloadFile() {
let filename = (this.currentPath+"/"+this.contextMenu.item.filename);
let link = document.createElement("a");
link.download = name;
link.href = '//' + this.hostname + ':' + this.port + '/server/files' + filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
createFolder() {
this.dialogCreateFolder.name = "";
this.dialogCreateFolder.show = true;
},
createFolderAction() {
this.dialogCreateFolder.show = false;
this.$socket.sendObj('server.files.post_directory', {
path: this.currentPath.substring(1)+"/"+this.dialogCreateFolder.name
}, 'files/getCreateDir');
},
createFile() {
this.dialogCreateFile.name = "";
this.dialogCreateFile.show = true;
},
createFileAction() {
let file = new File([""], this.dialogCreateFile.name);
let formData = new FormData();
formData.append('file', file);
formData.append('root', 'config');
if(this.currentPath.length > 7) formData.append('path', this.currentPath.substring(8));
axios.post('//' + this.hostname + ':' + this.port + '/server/files/upload',
formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}
).then(() => {
this.dialogCreateFile.show = false;
this.dialogCreateFile.name = "";
}).catch(() => {
window.console.error("Error save "+this.editor.item.filename);
});
},
renameFile(item) {
this.dialogRenameFile.item = item;
this.dialogRenameFile.newName = item.filename;
this.dialogRenameFile.show = true;
},
renameFileAction() {
this.dialogRenameFile.show = false;
this.$socket.sendObj('server.files.move', {
source: this.currentPath+"/"+this.dialogRenameFile.item.filename,
dest: this.currentPath+"/"+this.dialogRenameFile.newName
}, 'files/getMove');
},
removeFile() {
this.$socket.sendObj('server.files.delete_file', { path: this.currentPath+"/"+this.contextMenu.item.filename }, 'files/getDeleteFile');
},
deleteDirectoryAction: function() {
this.$socket.sendObj('server.files.delete_directory', { path: this.currentPath+"/"+this.contextMenu.item.filename }, 'files/getDeleteDir');
},
uploadFileButton: function() {
this.$refs.fileUpload.click()
},
async uploadFile() {
if (this.$refs.fileUpload.files.length) {
this.$store.commit('socket/addLoading', { name: 'configFileUpload' })
let successFiles = []
this.uploadSnackbar.number = 0
this.uploadSnackbar.max = this.$refs.fileUpload.files.length
for (const file of this.$refs.fileUpload.files) {
this.uploadSnackbar.number++
const result = await this.doUploadFile(file)
successFiles.push(result)
}
this.$store.commit('socket/removeLoading', { name: 'configFileUpload' })
for(const file of successFiles) {
this.$toast.success("Upload of "+file+" successful!")
}
this.$refs.fileUpload.value = ''
}
},
doUploadFile: function(file) {
let toast = this.$toast;
let formData = new FormData();
let filename = file.name.replace(" ", "_");
this.uploadSnackbar.filename = filename
this.uploadSnackbar.status = true
this.uploadSnackbar.percent = 0
this.uploadSnackbar.speed = 0
this.uploadSnackbar.lastProgress.loaded = 0
this.uploadSnackbar.lastProgress.time = 0
formData.append('root', 'config');
formData.append('file', file, (this.currentPath+"/"+filename).substring(7));
this.$store.commit('socket/addLoading', { name: 'configFileUpload' });
return new Promise(resolve => {
this.uploadSnackbar.cancelTokenSource = axios.CancelToken.source();
axios.post('//' + this.hostname + ':' + this.port + '/server/files/upload',
formData, {
headers: { 'Content-Type': 'multipart/form-data' },
cancelToken: this.uploadSnackbar.cancelTokenSource.token,
onUploadProgress: (progressEvent) => {
this.uploadSnackbar.percent = (progressEvent.loaded * 100) / progressEvent.total
if (this.uploadSnackbar.lastProgress.time) {
const time = progressEvent.timeStamp - this.uploadSnackbar.lastProgress.time
const data = progressEvent.loaded - this.uploadSnackbar.lastProgress.loaded
if (time) this.uploadSnackbar.speed = data / (time / 1000)
}
this.uploadSnackbar.lastProgress.time = progressEvent.timeStamp
this.uploadSnackbar.lastProgress.loaded = progressEvent.loaded
this.uploadSnackbar.total = progressEvent.total
}
}
).then((result) => {
this.uploadSnackbar.status = false
resolve(result.data.result)
}).catch(() => {
this.uploadSnackbar.status = false
this.$store.commit('socket/removeLoading', { name: 'configFileUpload' });
toast.error("Cannot upload the file!");
})
})
},
cancelUpload: function() {
this.uploadSnackbar.cancelTokenSource.cancel()
this.uploadSnackbar.status = false
},
},
watch: {
config: {
deep: true,
handler() {
this.loadPath();
}
},
config_examples: {
deep: true,
handler() {
this.loadPath();
}
},
filetree: {
deep: true,
handler() {
this.loadPath();
}
},
currentPath: {
handler() {
this.loadPath();
}
},
showHiddenFiles: {
handler() {
this.loadPath();
}
}
}
}
</script>