feat: Added modified file tracking and a confirmation (#393)
dialog on unsaved changes. This can be disabled if the user wishes to do so. Also allows the user to configure if the editor can be closed by hitting the "ESC" key.
This commit is contained in:
parent
4ece97a535
commit
0a0c456faa
@ -6,19 +6,19 @@
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<v-dialog v-model="show"
|
||||
<v-dialog persistent v-model="show"
|
||||
fullscreen
|
||||
hide-overlay
|
||||
:transition="false"
|
||||
@close="close"
|
||||
@keydown.esc="close"
|
||||
@keydown.esc="escClose"
|
||||
>
|
||||
<v-card>
|
||||
<v-toolbar dark color="primary">
|
||||
<v-btn icon dark @click="close">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
<v-toolbar-title>{{ filepath ? filepath.slice(1)+"/" : "" }}{{ filename }}</v-toolbar-title>
|
||||
<v-toolbar-title>{{ filepath ? filepath.slice(1)+"/" : "" }}{{ filename }} {{changed}}</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-toolbar-items>
|
||||
<v-btn dark text href="https://www.klipper3d.org/Config_Reference.html" v-if="restartServiceName === 'klipper'" target="_blank" class="d-none d-md-flex"><v-icon small class="mr-1">mdi-help</v-icon>{{ $t('Editor.ConfigReference') }}</v-btn>
|
||||
@ -57,6 +57,48 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
<v-dialog v-model="dialogConfirmChange" persistent :width="600">
|
||||
<v-card dark>
|
||||
<v-toolbar flat dense color="primary">
|
||||
<v-toolbar-title>
|
||||
<span class="subheading">
|
||||
<v-icon class="mdi mdi-help-circle" left></v-icon> {{ $t('Editor.UnsavedChanges') }}
|
||||
</span>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn small class="minwidth-0" @click="dialogConfirmChange = false"><v-icon small>mdi-close-thick</v-icon></v-btn>
|
||||
</v-toolbar>
|
||||
<v-card-text class="pt-3">
|
||||
<v-container class="pb-0">
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<p class="body-1 mb-2">{{ $t('Editor.UnsavedChangesMessage', {filename: filename}) }}</p>
|
||||
<p class="body-2">{{ $t('Editor.UnsavedChangesSubMessage') }}</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="dialogConfirmChange = false">
|
||||
{{ $t('Editor.Cancel') }}
|
||||
</v-btn>
|
||||
<v-btn @click="discardChanges">
|
||||
{{ $t('Editor.DontSave') }}
|
||||
</v-btn>
|
||||
<template v-if="restartServiceName != null">
|
||||
<v-btn @click="save(restartServiceName)">
|
||||
{{ $t('Editor.SaveRestart') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-btn color="primary" @click="save">
|
||||
{{ $t('Editor.SaveClose') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -65,16 +107,23 @@ import {Component, Mixins} from 'vue-property-decorator'
|
||||
import BaseMixin from '@/components/mixins/base'
|
||||
import {formatFilesize} from '@/plugins/helpers'
|
||||
import Codemirror from '@/components/inputs/Codemirror.vue'
|
||||
|
||||
@Component({
|
||||
components: {Codemirror}
|
||||
})
|
||||
export default class TheEditor extends Mixins(BaseMixin) {
|
||||
private dialogConfirmChange = false
|
||||
|
||||
formatFilesize = formatFilesize
|
||||
|
||||
refs!: {
|
||||
editor: any
|
||||
}
|
||||
|
||||
get changed() {
|
||||
return this.$store.state.editor.changed ? '*' : ''
|
||||
}
|
||||
|
||||
get show() {
|
||||
return this.$store.state.editor.bool ?? false
|
||||
}
|
||||
@ -146,11 +195,33 @@ export default class TheEditor extends Mixins(BaseMixin) {
|
||||
this.$store.dispatch('editor/cancelLoad')
|
||||
}
|
||||
|
||||
escClose() {
|
||||
if (this.$store.state.gui.editor.escToClose)
|
||||
this.close()
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.$store.state.gui.editor.confirmUnsavedChanges)
|
||||
this.promptUnsavedChanges()
|
||||
else
|
||||
this.$store.dispatch('editor/close')
|
||||
}
|
||||
|
||||
discardChanges() {
|
||||
this.dialogConfirmChange = false
|
||||
this.$store.dispatch('editor/close')
|
||||
}
|
||||
|
||||
promptUnsavedChanges() {
|
||||
if (!this.$store.state.editor.changed)
|
||||
this.$store.dispatch('editor/close')
|
||||
else
|
||||
this.dialogConfirmChange = true
|
||||
}
|
||||
|
||||
save(restartServiceName: string | null = null) {
|
||||
this.dialogConfirmChange = false
|
||||
|
||||
this.$store.dispatch('editor/saveFile', {
|
||||
content: this.sourcecode,
|
||||
restartServiceName: restartServiceName
|
||||
|
@ -59,6 +59,8 @@ import SettingsRemotePrintersTab from '@/components/settings/SettingsRemotePrint
|
||||
import SettingsThemeTab from '@/components/settings/SettingsThemeTab.vue'
|
||||
import SettingsDashboardTab from '@/components/settings/SettingsDashboardTab.vue'
|
||||
import SettingsGCodeViewerTab from '@/components/settings/SettingsGCodeViewerTab.vue'
|
||||
import SettingsEditorTab from '@/components/settings/SettingsEditorTab.vue'
|
||||
|
||||
import Panel from '@/components/ui/Panel.vue'
|
||||
@Component({
|
||||
components: {
|
||||
@ -72,7 +74,8 @@ import Panel from '@/components/ui/Panel.vue'
|
||||
SettingsWebcamTab,
|
||||
SettingsGeneralTab,
|
||||
SettingsDashboardTab,
|
||||
SettingsGCodeViewerTab
|
||||
SettingsGCodeViewerTab,
|
||||
SettingsEditorTab
|
||||
}
|
||||
})
|
||||
export default class TheSettingsMenu extends Mixins(BaseMixin) {
|
||||
@ -134,6 +137,11 @@ export default class TheSettingsMenu extends Mixins(BaseMixin) {
|
||||
icon: 'mdi-video-3d',
|
||||
name: 'g-code-viewer',
|
||||
title: this.$t('Settings.GCodeViewerTab.GCodeViewer')
|
||||
},
|
||||
{
|
||||
icon: 'mdi-file-document-edit-outline',
|
||||
name: 'editor',
|
||||
title: this.$t('Settings.EditorTab.Editor')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
45
src/components/settings/SettingsEditorTab.vue
Normal file
45
src/components/settings/SettingsEditorTab.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<settings-row :title="$t('Settings.EditorTab.UseEscToClose')" :sub-title="$t('Settings.EditorTab.UseEscToCloseDescription')" :dynamicSlotWidth="true">
|
||||
<v-switch v-model="escToClose" hide-details class="mt-0"></v-switch>
|
||||
</settings-row>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
<settings-row :title="$t('Settings.EditorTab.ConfirmUnsavedChanges')" :sub-title="$t('Settings.EditorTab.ConfirmUnsavedChangesDescription')" :dynamicSlotWidth="true">
|
||||
<v-switch v-model="confirmUnsavedChanges" hide-details class="mt-0"></v-switch>
|
||||
</settings-row>
|
||||
<v-divider class="my-2"></v-divider>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import Component from 'vue-class-component'
|
||||
import { Mixins } from 'vue-property-decorator'
|
||||
import BaseMixin from '@/components/mixins/base'
|
||||
import SettingsRow from '@/components/settings/SettingsRow.vue'
|
||||
@Component({
|
||||
components: {SettingsRow}
|
||||
})
|
||||
export default class SettingsEditorTab extends Mixins(BaseMixin) {
|
||||
|
||||
get escToClose() {
|
||||
return this.$store.state.gui.editor.escToClose
|
||||
}
|
||||
|
||||
set escToClose(newVal) {
|
||||
this.$store.dispatch('gui/saveSetting', {name: 'editor.escToClose', value: newVal })
|
||||
}
|
||||
|
||||
get confirmUnsavedChanges() {
|
||||
return this.$store.state.gui.editor.confirmUnsavedChanges
|
||||
}
|
||||
|
||||
set confirmUnsavedChanges(newVal) {
|
||||
this.$store.dispatch('gui/saveSetting', {name: 'editor.confirmUnsavedChanges', value: newVal })
|
||||
}
|
||||
}
|
||||
</script>
|
@ -105,7 +105,12 @@
|
||||
"SaveRestart": "Save & Restart",
|
||||
"SaveClose": "Save & close",
|
||||
"SuccessfullySaved": "{filename} successfully saved.",
|
||||
"FailedSave": "{filename} could not be uploaded!"
|
||||
"FailedSave": "{filename} could not be uploaded!",
|
||||
"UnsavedChanges": "Unsaved Changes",
|
||||
"UnsavedChangesMessage": "Do you want to save your changes made to {filename}?",
|
||||
"UnsavedChangesSubMessage": "Your changes will be lost if you don't save them. You can disable this message in the editor settings.",
|
||||
"DontSave": "Don't save",
|
||||
"Cancel": "Cancel"
|
||||
},
|
||||
"Files": {
|
||||
"GCodeFiles": "G-Code Files",
|
||||
@ -664,6 +669,13 @@
|
||||
"ShowAxes": "Show Axes",
|
||||
"MinFeed" :"Min Feed Rate",
|
||||
"MaxFeed" : "Max Feed Rate"
|
||||
},
|
||||
"EditorTab": {
|
||||
"Editor": "Editor",
|
||||
"UseEscToClose": "Use ESC to close editor",
|
||||
"UseEscToCloseDescription": "Allows the ESC key to close the editor",
|
||||
"ConfirmUnsavedChanges": "Prompt to save or discard unsaved changes",
|
||||
"ConfirmUnsavedChangesDescription": "If enabled, the editor requires a confirmation to either save or discard the changes made. If disabled, changes are silently discarded."
|
||||
}
|
||||
},
|
||||
"GCodeViewer":{
|
||||
|
@ -20,7 +20,9 @@ export const getDefaultState = (): EditorState => {
|
||||
total: 0,
|
||||
speed: '',
|
||||
},
|
||||
cancelToken: null
|
||||
cancelToken: null,
|
||||
loadedHash: '',
|
||||
changed: false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,8 +2,10 @@ import { getDefaultState } from './index'
|
||||
import {MutationTree} from 'vuex'
|
||||
import {EditorState} from '@/store/editor/types'
|
||||
import Vue from 'vue'
|
||||
import { sha256 } from 'js-sha256'
|
||||
|
||||
export const mutations: MutationTree<EditorState> = {
|
||||
|
||||
reset(state) {
|
||||
Object.assign(state, getDefaultState())
|
||||
},
|
||||
@ -25,6 +27,13 @@ export const mutations: MutationTree<EditorState> = {
|
||||
Vue.set(state, 'fileroot', payload.fileroot)
|
||||
Vue.set(state, 'filepath', payload.filepath)
|
||||
Vue.set(state, 'sourcecode', payload.file)
|
||||
|
||||
// Because the used editor converts all Windows-Style line endings with unix ones on load,
|
||||
// the hash is computed with the source always having unix-style line endings.
|
||||
// https://github.com/codemirror/CodeMirror/issues/3395
|
||||
|
||||
Vue.set(state, 'loadedHash', sha256(payload.file.replace(/(?:\r\n|\r|\n)/g, '\n')))
|
||||
Vue.set(state, 'changed', false)
|
||||
Vue.set(state, 'bool', true)
|
||||
},
|
||||
|
||||
@ -42,5 +51,24 @@ export const mutations: MutationTree<EditorState> = {
|
||||
|
||||
updateSourcecode(state, payload) {
|
||||
Vue.set(state, 'sourcecode', payload)
|
||||
|
||||
// To check if a file has been changed by the user, we need to calculate a hash
|
||||
// (or otherwise we would need to save the full file in memory twice). Simply listening
|
||||
// to the changed event is not enough, because if the user types an "a" and then deletes
|
||||
// the "a" again, the file would still be shown as changed, even though the edited file
|
||||
// is equal to the stored file.
|
||||
|
||||
// I've tested this functionality with huge text files (60MB G-Code) and while calculating
|
||||
// the hash took 2 seconds per run, the editor itself is pretty laggy even without hash
|
||||
// calculations. Hash calculations with typical config file sizes (50KB) only take 1 or 2ms
|
||||
// on my machine, so I guess this is acceptable for most use cases.
|
||||
|
||||
if (sha256(payload) != state.loadedHash)
|
||||
state.changed = true
|
||||
else
|
||||
state.changed = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
@ -13,5 +13,7 @@ export interface EditorState {
|
||||
total: number
|
||||
speed: string
|
||||
},
|
||||
cancelToken: any
|
||||
cancelToken: any,
|
||||
loadedHash: string,
|
||||
changed: boolean
|
||||
}
|
@ -167,7 +167,9 @@ export const getDefaultState = (): GuiState => {
|
||||
}
|
||||
},
|
||||
editor: {
|
||||
minimap: false
|
||||
minimap: false,
|
||||
escToClose: true,
|
||||
confirmUnsavedChanges: true
|
||||
},
|
||||
//moonraker DB api dont accept camel case key names
|
||||
remotePrinters: [],
|
||||
@ -194,7 +196,7 @@ export const getDefaultState = (): GuiState => {
|
||||
voxelWidth: 1,
|
||||
voxelHeight: 1,
|
||||
specularLighting: false,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user