From ea02bcd5693584333aff7c0bd299c2c5c9d1e6ba Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 27 Oct 2026 16:05:10 +0100 Subject: [PATCH 1/3] fix(viewer): do not start a save while another one is running Autosave runs every 10 seconds next to the save button and Ctrl+S. When two saves overlapped, the second request still carried the old mtime and failed with "The file you are working on was updated in the meantime", although nobody else had touched the file. A save requested while one is running now waits for it and runs afterwards, so edits made in between are saved as well and a pending close still gets its answer. The map is marked unchanged when the request goes out and changed again when it fails, so edits made during a save are no longer lost. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger (cherry picked from commit ea2776dfb751c07af6ff194c168aefbeee704a68) Signed-off-by: Andy Scherzinger --- src/viewer.js | 68 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/src/viewer.js b/src/viewer.js index f701c34a..fdd67840 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -110,6 +110,8 @@ normalizeMouseWheel(); const MindMap = { _changed: false, + _saving: false, + _saveQueue: [], _autoSaveTimer: null, _clearStatusMessageTimer: null, _loadStatus: false, @@ -224,25 +226,55 @@ normalizeMouseWheel(); }, save(onComplete) { const self = this - if (self._changed) { - self.updateSaveButtonInfo(t('files_mindmap', 'Saving…')) - const data = JSON.stringify(minder.exportJson()) - window.parent.OCA.FilesMindMap.save(data, function(msg) { - self.updateSaveButtonInfo(msg) - self._changed = false - self.restoreSaveButtonInfo(3000) - if (undefined !== onComplete) { - onComplete(true, msg) - } - }, function(msg) { - self.updateSaveButtonInfo(msg) - self.restoreSaveButtonInfo(3000) - if (undefined !== onComplete) { - onComplete(false, msg) - } - }) - self.restoreSaveButtonInfo(6000) + // A second request before the first one returns would fail the server's mtime check + if (self._saving) { + self._saveQueue.push(onComplete) + return + } + if (!self._changed) { + if (undefined !== onComplete) { + onComplete(true) + } + return } + const finish = function(status, msg) { + self._saving = false + if (undefined !== onComplete) { + onComplete(status, msg) + } + const queued = self._saveQueue.splice(0) + const notify = function(queuedStatus, queuedMsg) { + queued.forEach(function(callback) { + if (undefined !== callback) { + callback(queuedStatus, queuedMsg) + } + }) + } + if (queued.length === 0) { + return + } + if (status) { + self.save(notify) + } else { + notify(false, msg) + } + } + self._saving = true + // Edits made while the request is out mark the map as changed again + self._changed = false + self.updateSaveButtonInfo(t('files_mindmap', 'Saving…')) + const data = JSON.stringify(minder.exportJson()) + window.parent.OCA.FilesMindMap.save(data, function(msg) { + self.updateSaveButtonInfo(msg) + self.restoreSaveButtonInfo(3000) + finish(true, msg) + }, function(msg) { + self._changed = true + self.updateSaveButtonInfo(msg) + self.restoreSaveButtonInfo(3000) + finish(false, msg) + }) + self.restoreSaveButtonInfo(6000) }, startAutoSaveTimer() { const self = this From 0db02cc8ee1b189474175b2ae4ed0b1d86e8d1c0 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 27 Oct 2026 16:05:18 +0100 Subject: [PATCH 2/3] fix(viewer): save on Ctrl+S wherever the focus is Inside the editor, Ctrl+S was handled in the bubble phase, where the editor's own key handlers could swallow it first. It is now handled in the capture phase and checks event.key instead of the deprecated event.which. With the focus outside the editor iframe, for example on the viewer's header, Ctrl+S opened the browser's save dialog. The viewer component now catches it while it is shown and asks the editor to save. Backport of 4b99f03 from main, where the viewer handler is a custom element; here the same handling lives in the Vue component. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- src/__tests__/MindMap.spec.js | 47 +++++++++++++++++++++++++++++++++++ src/viewer.js | 11 ++++---- src/views/MindMap.js | 25 +++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/__tests__/MindMap.spec.js b/src/__tests__/MindMap.spec.js index 507ef3fc..f40f259f 100644 --- a/src/__tests__/MindMap.spec.js +++ b/src/__tests__/MindMap.spec.js @@ -49,6 +49,13 @@ function mountMindMap(dataOverrides = {}) { }) } +/** + * Mount into the document, so the iframe has a window to talk to. + */ +function mountAttached() { + return shallowMount(MindMap, { attachTo: document.body, global: { mixins: [viewerMixin] } }) +} + describe('MindMap', () => { beforeEach(() => { window.OCA = { FilesMindMap: { setFile: vi.fn() } } @@ -105,4 +112,44 @@ describe('MindMap', () => { expect(window.OCA.FilesMindMap.setFile).toHaveBeenCalled() }) }) + + describe('Ctrl+S', () => { + it('saves the mind map instead of the browser saving the page', () => { + const wrapper = mountAttached() + const save = vi.fn() + wrapper.element.contentWindow.MindMap = { save } + + const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true }) + document.dispatchEvent(event) + + expect(save).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(true) + wrapper.unmount() + }) + + it('leaves other keys alone', () => { + const wrapper = mountAttached() + const save = vi.fn() + wrapper.element.contentWindow.MindMap = { save } + + const event = new KeyboardEvent('keydown', { key: 's', cancelable: true }) + document.dispatchEvent(event) + + expect(save).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + wrapper.unmount() + }) + + it('stops handling Ctrl+S once the viewer closes', () => { + const add = vi.spyOn(document, 'addEventListener') + const remove = vi.spyOn(document, 'removeEventListener') + const wrapper = mountAttached() + const [, handler] = add.mock.calls.find(([type]) => type === 'keydown') + wrapper.unmount() + + expect(remove).toHaveBeenCalledWith('keydown', handler) + add.mockRestore() + remove.mockRestore() + }) + }) }) diff --git a/src/viewer.js b/src/viewer.js index fdd67840..46108aa0 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -154,13 +154,14 @@ normalizeMouseWheel(); }, initHotkey() { const self = this - window.$(document).keydown(function(e) { - if ((e.ctrlKey || e.metaKey) && e.which === 83) { - self.save() + // Capture phase, so the editor's own key handlers cannot swallow it + document.addEventListener('keydown', function(e) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault() - return false + e.stopPropagation() + self.save() } - }) + }, true) }, bindEvent() { const self = this diff --git a/src/views/MindMap.js b/src/views/MindMap.js index b314c218..3da217d5 100644 --- a/src/views/MindMap.js +++ b/src/views/MindMap.js @@ -39,9 +39,34 @@ export default { if (OCA.FilesMindMap) { OCA.FilesMindMap.setFile(this.file) } + document.addEventListener('keydown', this.onKeyDown) this.doneLoading() }, + // Vue 2.7 (viewer app) calls beforeDestroy; Vue 3 (test env) calls beforeUnmount. + beforeDestroy() { + document.removeEventListener('keydown', this.onKeyDown) + }, + + beforeUnmount() { + document.removeEventListener('keydown', this.onKeyDown) + }, + + methods: { + /** + * Save the mind map on Ctrl+S while the focus is outside the editor iframe, + * instead of the browser saving the page. + * + * @param {KeyboardEvent} event the key event + */ + onKeyDown(event) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault() + this.$el.contentWindow?.MindMap?.save() + } + }, + }, + render(h) { // Vue 2.7 passes h as an argument; Vue 3 (test env) does not — fall back to the peer dep import. const createElement = typeof h === 'function' ? h : _h From 08fdb8ea371c545fbb78bfb8e72494718a4ebad6 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 27 Oct 2026 16:07:59 +0100 Subject: [PATCH 3/3] fix(viewer): do not open the previous mind map when the file is unknown The viewer component looks the opened file up in the viewer's file list. When it is not there yet, setFile() got undefined and threw on file.filename, leaving the editor with the previously opened file: it then loaded that map, and autosave wrote to it, while the viewer showed the new file's name. setFile() now forgets the previous file in that case, so loading fails with "Could not load the file" and the viewer closes. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- src/__tests__/mindmap.spec.js | 6 ++++++ src/mindmap.js | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/__tests__/mindmap.spec.js b/src/__tests__/mindmap.spec.js index e0173758..249bb308 100644 --- a/src/__tests__/mindmap.spec.js +++ b/src/__tests__/mindmap.spec.js @@ -214,6 +214,12 @@ describe('FilesMindMap', () => { FilesMindMap.setFile({ filename: '/docs/sub/test.km', basename: 'test.km' }) expect(FilesMindMap._currentContext).toEqual(expect.objectContaining({ dir: '/docs/sub' })) }) + + it('forgets the previous file when the viewer passes none', () => { + FilesMindMap.setFile({ filename: '/docs/test.km', basename: 'test.km' }) + expect(() => FilesMindMap.setFile(undefined)).not.toThrow() + expect(FilesMindMap._file).toEqual({}) + }) }) // ─── Public share detection ──────────────────────────────────────────────── diff --git a/src/mindmap.js b/src/mindmap.js index 65466c55..e769e062 100644 --- a/src/mindmap.js +++ b/src/mindmap.js @@ -215,6 +215,12 @@ const FilesMindMap = { }, setFile(file) { + // Forget the previous file, so the editor cannot load or save it in place of this one + if (!file) { + logger.warn('The viewer passed no file to open') + this._file = {} + return + } const filename = file.filename + '' const basename = file.basename + ''