diff --git a/src/components/TheEditor.vue b/src/components/TheEditor.vue index 5dcc24aa..271706f0 100644 --- a/src/components/TheEditor.vue +++ b/src/components/TheEditor.vue @@ -6,19 +6,19 @@ + + + + + + {{ $t('Editor.UnsavedChanges') }} + + + + mdi-close-thick + + + + + + +

{{ $t('Editor.UnsavedChangesMessage', {filename: filename}) }}

+

{{ $t('Editor.UnsavedChangesSubMessage') }}

+
+
+ + + + + {{ $t('Editor.Cancel') }} + + + {{ $t('Editor.DontSave') }} + + + + {{ $t('Editor.SaveClose') }} + + +
+
+
+
@@ -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 diff --git a/src/components/TheSettingsMenu.vue b/src/components/TheSettingsMenu.vue index 66c134bc..e55860d1 100644 --- a/src/components/TheSettingsMenu.vue +++ b/src/components/TheSettingsMenu.vue @@ -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') } ] } diff --git a/src/components/settings/SettingsEditorTab.vue b/src/components/settings/SettingsEditorTab.vue new file mode 100644 index 00000000..f00039dc --- /dev/null +++ b/src/components/settings/SettingsEditorTab.vue @@ -0,0 +1,45 @@ + + + \ No newline at end of file diff --git a/src/locales/en.json b/src/locales/en.json index e5cd4aaf..34e71557 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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":{ diff --git a/src/store/editor/index.ts b/src/store/editor/index.ts index cfb7b252..5dff1e16 100644 --- a/src/store/editor/index.ts +++ b/src/store/editor/index.ts @@ -20,7 +20,9 @@ export const getDefaultState = (): EditorState => { total: 0, speed: '', }, - cancelToken: null + cancelToken: null, + loadedHash: '', + changed: false } } diff --git a/src/store/editor/mutations.ts b/src/store/editor/mutations.ts index be8fcc23..874fb38a 100644 --- a/src/store/editor/mutations.ts +++ b/src/store/editor/mutations.ts @@ -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 = { + reset(state) { Object.assign(state, getDefaultState()) }, @@ -25,6 +27,13 @@ export const mutations: MutationTree = { 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 = { 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 } + } + + diff --git a/src/store/editor/types.ts b/src/store/editor/types.ts index d8e622cb..fa2d6f3d 100644 --- a/src/store/editor/types.ts +++ b/src/store/editor/types.ts @@ -13,5 +13,7 @@ export interface EditorState { total: number speed: string }, - cancelToken: any + cancelToken: any, + loadedHash: string, + changed: boolean } \ No newline at end of file diff --git a/src/store/gui/index.ts b/src/store/gui/index.ts index 9c5d13f7..1cc5600f 100644 --- a/src/store/gui/index.ts +++ b/src/store/gui/index.ts @@ -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, - } + }, } }