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:
Felicia Hummel
2021-10-27 22:41:15 +02:00
committed by GitHub
parent 4ece97a535
commit 0a0c456faa
8 changed files with 179 additions and 9 deletions

View File

@@ -6,19 +6,19 @@
<template> <template>
<div> <div>
<v-dialog v-model="show" <v-dialog persistent v-model="show"
fullscreen fullscreen
hide-overlay hide-overlay
:transition="false" :transition="false"
@close="close" @close="close"
@keydown.esc="close" @keydown.esc="escClose"
> >
<v-card> <v-card>
<v-toolbar dark color="primary"> <v-toolbar dark color="primary">
<v-btn icon dark @click="close"> <v-btn icon dark @click="close">
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
</v-btn> </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-spacer></v-spacer>
<v-toolbar-items> <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> <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> </v-btn>
</template> </template>
</v-snackbar> </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> </div>
</template> </template>
@@ -65,16 +107,23 @@ import {Component, Mixins} from 'vue-property-decorator'
import BaseMixin from '@/components/mixins/base' import BaseMixin from '@/components/mixins/base'
import {formatFilesize} from '@/plugins/helpers' import {formatFilesize} from '@/plugins/helpers'
import Codemirror from '@/components/inputs/Codemirror.vue' import Codemirror from '@/components/inputs/Codemirror.vue'
@Component({ @Component({
components: {Codemirror} components: {Codemirror}
}) })
export default class TheEditor extends Mixins(BaseMixin) { export default class TheEditor extends Mixins(BaseMixin) {
private dialogConfirmChange = false
formatFilesize = formatFilesize formatFilesize = formatFilesize
refs!: { refs!: {
editor: any editor: any
} }
get changed() {
return this.$store.state.editor.changed ? '*' : ''
}
get show() { get show() {
return this.$store.state.editor.bool ?? false return this.$store.state.editor.bool ?? false
} }
@@ -146,11 +195,33 @@ export default class TheEditor extends Mixins(BaseMixin) {
this.$store.dispatch('editor/cancelLoad') this.$store.dispatch('editor/cancelLoad')
} }
escClose() {
if (this.$store.state.gui.editor.escToClose)
this.close()
}
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') 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) { save(restartServiceName: string | null = null) {
this.dialogConfirmChange = false
this.$store.dispatch('editor/saveFile', { this.$store.dispatch('editor/saveFile', {
content: this.sourcecode, content: this.sourcecode,
restartServiceName: restartServiceName restartServiceName: restartServiceName

View File

@@ -59,6 +59,8 @@ import SettingsRemotePrintersTab from '@/components/settings/SettingsRemotePrint
import SettingsThemeTab from '@/components/settings/SettingsThemeTab.vue' import SettingsThemeTab from '@/components/settings/SettingsThemeTab.vue'
import SettingsDashboardTab from '@/components/settings/SettingsDashboardTab.vue' import SettingsDashboardTab from '@/components/settings/SettingsDashboardTab.vue'
import SettingsGCodeViewerTab from '@/components/settings/SettingsGCodeViewerTab.vue' import SettingsGCodeViewerTab from '@/components/settings/SettingsGCodeViewerTab.vue'
import SettingsEditorTab from '@/components/settings/SettingsEditorTab.vue'
import Panel from '@/components/ui/Panel.vue' import Panel from '@/components/ui/Panel.vue'
@Component({ @Component({
components: { components: {
@@ -72,7 +74,8 @@ import Panel from '@/components/ui/Panel.vue'
SettingsWebcamTab, SettingsWebcamTab,
SettingsGeneralTab, SettingsGeneralTab,
SettingsDashboardTab, SettingsDashboardTab,
SettingsGCodeViewerTab SettingsGCodeViewerTab,
SettingsEditorTab
} }
}) })
export default class TheSettingsMenu extends Mixins(BaseMixin) { export default class TheSettingsMenu extends Mixins(BaseMixin) {
@@ -134,6 +137,11 @@ export default class TheSettingsMenu extends Mixins(BaseMixin) {
icon: 'mdi-video-3d', icon: 'mdi-video-3d',
name: 'g-code-viewer', name: 'g-code-viewer',
title: this.$t('Settings.GCodeViewerTab.GCodeViewer') title: this.$t('Settings.GCodeViewerTab.GCodeViewer')
},
{
icon: 'mdi-file-document-edit-outline',
name: 'editor',
title: this.$t('Settings.EditorTab.Editor')
} }
] ]
} }

View 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>

View File

@@ -105,7 +105,12 @@
"SaveRestart": "Save & Restart", "SaveRestart": "Save & Restart",
"SaveClose": "Save & close", "SaveClose": "Save & close",
"SuccessfullySaved": "{filename} successfully saved.", "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": { "Files": {
"GCodeFiles": "G-Code Files", "GCodeFiles": "G-Code Files",
@@ -664,6 +669,13 @@
"ShowAxes": "Show Axes", "ShowAxes": "Show Axes",
"MinFeed" :"Min Feed Rate", "MinFeed" :"Min Feed Rate",
"MaxFeed" : "Max 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":{ "GCodeViewer":{

View File

@@ -20,7 +20,9 @@ export const getDefaultState = (): EditorState => {
total: 0, total: 0,
speed: '', speed: '',
}, },
cancelToken: null cancelToken: null,
loadedHash: '',
changed: false
} }
} }

View File

@@ -2,8 +2,10 @@ import { getDefaultState } from './index'
import {MutationTree} from 'vuex' import {MutationTree} from 'vuex'
import {EditorState} from '@/store/editor/types' import {EditorState} from '@/store/editor/types'
import Vue from 'vue' import Vue from 'vue'
import { sha256 } from 'js-sha256'
export const mutations: MutationTree<EditorState> = { export const mutations: MutationTree<EditorState> = {
reset(state) { reset(state) {
Object.assign(state, getDefaultState()) Object.assign(state, getDefaultState())
}, },
@@ -25,6 +27,13 @@ export const mutations: MutationTree<EditorState> = {
Vue.set(state, 'fileroot', payload.fileroot) Vue.set(state, 'fileroot', payload.fileroot)
Vue.set(state, 'filepath', payload.filepath) Vue.set(state, 'filepath', payload.filepath)
Vue.set(state, 'sourcecode', payload.file) 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) Vue.set(state, 'bool', true)
}, },
@@ -42,5 +51,24 @@ export const mutations: MutationTree<EditorState> = {
updateSourcecode(state, payload) { updateSourcecode(state, payload) {
Vue.set(state, 'sourcecode', 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
} }
} }

View File

@@ -13,5 +13,7 @@ export interface EditorState {
total: number total: number
speed: string speed: string
}, },
cancelToken: any cancelToken: any,
loadedHash: string,
changed: boolean
} }

View File

@@ -167,7 +167,9 @@ export const getDefaultState = (): GuiState => {
} }
}, },
editor: { editor: {
minimap: false minimap: false,
escToClose: true,
confirmUnsavedChanges: true
}, },
//moonraker DB api dont accept camel case key names //moonraker DB api dont accept camel case key names
remotePrinters: [], remotePrinters: [],
@@ -194,7 +196,7 @@ export const getDefaultState = (): GuiState => {
voxelWidth: 1, voxelWidth: 1,
voxelHeight: 1, voxelHeight: 1,
specularLighting: false, specularLighting: false,
} },
} }
} }