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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 179 additions and 9 deletions

View File

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

View File

@ -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')
}
]
}

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",
"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":{

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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,
}
},
}
}