From 670d2f8cad0b64f83577b689e4a9fd8f55495c36 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 15 Mar 2024 11:53:24 +0100 Subject: [PATCH 1/6] fix(sync): If `baseVersionEtag` changed, reset frontend `baseVersionEtag` changes when a new document session got initialized, e.g. after an old document session without session clients got cleaned up, or because the markdown file got changed via webdav. Detect this in the client and ask the user to reload the page for resetting the session. Signed-off-by: Jonas --- lib/Controller/PublicSessionController.php | 4 ++-- lib/Controller/SessionController.php | 4 ++-- lib/Service/ApiService.php | 5 ++++- src/components/Editor.vue | 1 + src/services/SessionApi.js | 3 ++- src/services/SyncService.js | 6 ++++-- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/Controller/PublicSessionController.php b/lib/Controller/PublicSessionController.php index 3d0da080e8c..be38220f2ff 100644 --- a/lib/Controller/PublicSessionController.php +++ b/lib/Controller/PublicSessionController.php @@ -80,8 +80,8 @@ protected function isPasswordProtected(): bool { #[NoAdminRequired] #[PublicPage] - public function create(string $token, ?string $file = null, ?string $guestName = null): DataResponse { - return $this->apiService->create(null, $file, $token, $guestName); + public function create(string $token, ?string $file = null, ?string $baseVersionEtag = null, ?string $guestName = null): DataResponse { + return $this->apiService->create(null, $file, $baseVersionEtag, $token, $guestName); } #[NoAdminRequired] diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index aa935f57879..09b056da07d 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -57,8 +57,8 @@ public function __construct( } #[NoAdminRequired] - public function create(?int $fileId = null, ?string $file = null): DataResponse { - return $this->apiService->create($fileId, $file, null, null); + public function create(?int $fileId = null, ?string $file = null, ?string $baseVersionEtag = null): DataResponse { + return $this->apiService->create($fileId, $file, $baseVersionEtag, null, null); } #[NoAdminRequired] diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php index 9d3fd6410e3..95dc3310215 100644 --- a/lib/Service/ApiService.php +++ b/lib/Service/ApiService.php @@ -71,7 +71,7 @@ public function __construct(IRequest $request, $this->l10n = $l10n; } - public function create(?int $fileId = null, ?string $filePath = null, ?string $token = null, ?string $guestName = null): DataResponse { + public function create(?int $fileId = null, ?string $filePath = null, ?string $baseVersionEtag = null, ?string $token = null, ?string $guestName = null): DataResponse { try { if ($token) { $file = $this->documentService->getFileByShareToken($token, $this->request->getParam('filePath')); @@ -115,6 +115,9 @@ public function create(?int $fileId = null, ?string $filePath = null, ?string $t $this->sessionService->removeInactiveSessionsWithoutSteps($file->getId()); $document = $this->documentService->getDocument($file->getId()); $freshSession = $document === null; + if ($baseVersionEtag && $baseVersionEtag !== $document?->getBaseVersionEtag()) { + return new DataResponse($this->l10n->t('Editing session has expired. Please reload the page.'), Http::STATUS_CONFLICT); + } if ($freshSession) { $this->logger->info('Create new document of ' . $file->getId()); diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 6378aded67d..ddd0b122359 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -374,6 +374,7 @@ export default { guestName, shareToken: this.shareToken, filePath: this.relativePath, + baseVersionEtag: this.$syncService?.baseVersionEtag, forceRecreate: this.forceRecreate, serialize: this.isRichEditor ? (content) => createMarkdownSerializer(this.$editor.schema).serialize(content ?? this.$editor.state.doc) diff --git a/src/services/SessionApi.js b/src/services/SessionApi.js index 69d848b3cd3..96bf299ad0a 100644 --- a/src/services/SessionApi.js +++ b/src/services/SessionApi.js @@ -38,9 +38,10 @@ class SessionApi { this.#options = options } - open({ fileId }) { + open({ fileId, baseVersionEtag }) { return axios.put(this.#url(`session/${fileId}/create`), { fileId, + baseVersionEtag, filePath: this.#options.filePath, token: this.#options.shareToken, guestName: this.#options.guestName, diff --git a/src/services/SyncService.js b/src/services/SyncService.js index 8172f0aba01..3c753b3624b 100644 --- a/src/services/SyncService.js +++ b/src/services/SyncService.js @@ -68,7 +68,7 @@ class SyncService { #sendIntervalId - constructor({ serialize, getDocumentState, ...options }) { + constructor({ baseVersionEtag, serialize, getDocumentState, ...options }) { /** @type {import('mitt').Emitter} _bus */ this._bus = mitt() @@ -82,6 +82,7 @@ class SyncService { this.lastStepPush = Date.now() this.version = null + this.baseVersionEtag = baseVersionEtag this.sending = false this.#sendIntervalId = null @@ -94,7 +95,7 @@ class SyncService { const connect = initialSession ? Promise.resolve(new Connection({ data: initialSession }, {})) - : this._api.open({ fileId }) + : this._api.open({ fileId, baseVersionEtag: this.baseVersionEtag }) .catch(error => this._emitError(error)) this.connection = await connect @@ -104,6 +105,7 @@ class SyncService { } this.backend = new PollingBackend(this, this.connection) this.version = this.connection.docStateVersion + this.baseVersionEtag = this.connection.document.baseVersionEtag this.emit('opened', { ...this.connection.state, version: this.version, From f61fb6f69dbdb177cc22dc6db4b63ffedc72c531 Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 18 Mar 2024 19:05:30 +0100 Subject: [PATCH 2/6] fix(Middleware): Response with 412 if `baseVersionEtag` doesn't match Signed-off-by: Jonas --- composer/composer/autoload_classmap.php | 2 ++ composer/composer/autoload_static.php | 2 ++ lib/Controller/PublicSessionController.php | 4 +++ lib/Controller/SessionController.php | 4 +++ ...nvalidDocumentBaseVersionEtagException.php | 9 +++++ .../RequireDocumentBaseVersionEtag.php | 9 +++++ lib/Middleware/SessionMiddleware.php | 33 +++++++++++++++++-- lib/Service/ApiService.php | 6 ++-- src/services/PollingBackend.js | 3 ++ src/services/SessionApi.js | 3 ++ src/services/SyncService.js | 4 ++- 11 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 lib/Exception/InvalidDocumentBaseVersionEtagException.php create mode 100644 lib/Middleware/Attribute/RequireDocumentBaseVersionEtag.php diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index dcff792ea42..dd4f029eb45 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -31,6 +31,7 @@ 'OCA\\Text\\Event\\LoadEditor' => $baseDir . '/../lib/Event/LoadEditor.php', 'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => $baseDir . '/../lib/Exception/DocumentHasUnsavedChangesException.php', 'OCA\\Text\\Exception\\DocumentSaveConflictException' => $baseDir . '/../lib/Exception/DocumentSaveConflictException.php', + 'OCA\\Text\\Exception\\InvalidDocumentBaseVersionEtagException' => $baseDir . '/../lib/Exception/InvalidDocumentBaseVersionEtagException.php', 'OCA\\Text\\Exception\\InvalidSessionException' => $baseDir . '/../lib/Exception/InvalidSessionException.php', 'OCA\\Text\\Exception\\UploadException' => $baseDir . '/../lib/Exception/UploadException.php', 'OCA\\Text\\Exception\\VersionMismatchException' => $baseDir . '/../lib/Exception/VersionMismatchException.php', @@ -45,6 +46,7 @@ 'OCA\\Text\\Listeners\\LoadViewerListener' => $baseDir . '/../lib/Listeners/LoadViewerListener.php', 'OCA\\Text\\Listeners\\NodeCopiedListener' => $baseDir . '/../lib/Listeners/NodeCopiedListener.php', 'OCA\\Text\\Listeners\\RegisterDirectEditorEventListener' => $baseDir . '/../lib/Listeners/RegisterDirectEditorEventListener.php', + 'OCA\\Text\\Middleware\\Attribute\\RequireDocumentBaseVersionEtag' => $baseDir . '/../lib/Middleware/Attribute/RequireDocumentBaseVersionEtag.php', 'OCA\\Text\\Middleware\\Attribute\\RequireDocumentSession' => $baseDir . '/../lib/Middleware/Attribute/RequireDocumentSession.php', 'OCA\\Text\\Middleware\\Attribute\\RequireDocumentSessionOrUserOrShareToken' => $baseDir . '/../lib/Middleware/Attribute/RequireDocumentSessionOrUserOrShareToken.php', 'OCA\\Text\\Middleware\\SessionMiddleware' => $baseDir . '/../lib/Middleware/SessionMiddleware.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index a172981b0ef..aac06275a14 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -46,6 +46,7 @@ class ComposerStaticInitText 'OCA\\Text\\Event\\LoadEditor' => __DIR__ . '/..' . '/../lib/Event/LoadEditor.php', 'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => __DIR__ . '/..' . '/../lib/Exception/DocumentHasUnsavedChangesException.php', 'OCA\\Text\\Exception\\DocumentSaveConflictException' => __DIR__ . '/..' . '/../lib/Exception/DocumentSaveConflictException.php', + 'OCA\\Text\\Exception\\InvalidDocumentBaseVersionEtagException' => __DIR__ . '/..' . '/../lib/Exception/InvalidDocumentBaseVersionEtagException.php', 'OCA\\Text\\Exception\\InvalidSessionException' => __DIR__ . '/..' . '/../lib/Exception/InvalidSessionException.php', 'OCA\\Text\\Exception\\UploadException' => __DIR__ . '/..' . '/../lib/Exception/UploadException.php', 'OCA\\Text\\Exception\\VersionMismatchException' => __DIR__ . '/..' . '/../lib/Exception/VersionMismatchException.php', @@ -60,6 +61,7 @@ class ComposerStaticInitText 'OCA\\Text\\Listeners\\LoadViewerListener' => __DIR__ . '/..' . '/../lib/Listeners/LoadViewerListener.php', 'OCA\\Text\\Listeners\\NodeCopiedListener' => __DIR__ . '/..' . '/../lib/Listeners/NodeCopiedListener.php', 'OCA\\Text\\Listeners\\RegisterDirectEditorEventListener' => __DIR__ . '/..' . '/../lib/Listeners/RegisterDirectEditorEventListener.php', + 'OCA\\Text\\Middleware\\Attribute\\RequireDocumentBaseVersionEtag' => __DIR__ . '/..' . '/../lib/Middleware/Attribute/RequireDocumentBaseVersionEtag.php', 'OCA\\Text\\Middleware\\Attribute\\RequireDocumentSession' => __DIR__ . '/..' . '/../lib/Middleware/Attribute/RequireDocumentSession.php', 'OCA\\Text\\Middleware\\Attribute\\RequireDocumentSessionOrUserOrShareToken' => __DIR__ . '/..' . '/../lib/Middleware/Attribute/RequireDocumentSessionOrUserOrShareToken.php', 'OCA\\Text\\Middleware\\SessionMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/SessionMiddleware.php', diff --git a/lib/Controller/PublicSessionController.php b/lib/Controller/PublicSessionController.php index be38220f2ff..9d6177584ed 100644 --- a/lib/Controller/PublicSessionController.php +++ b/lib/Controller/PublicSessionController.php @@ -25,6 +25,7 @@ namespace OCA\Text\Controller; +use OCA\Text\Middleware\Attribute\RequireDocumentBaseVersionEtag; use OCA\Text\Middleware\Attribute\RequireDocumentSession; use OCA\Text\Service\ApiService; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -92,6 +93,7 @@ public function close(int $documentId, int $sessionId, string $sessionToken): Da #[NoAdminRequired] #[PublicPage] + #[RequireDocumentBaseVersionEtag] #[RequireDocumentSession] public function push(int $documentId, int $sessionId, string $sessionToken, int $version, array $steps, string $awareness, string $token): DataResponse { return $this->apiService->push($this->getSession(), $this->getDocument(), $version, $steps, $awareness, $token); @@ -99,6 +101,7 @@ public function push(int $documentId, int $sessionId, string $sessionToken, int #[NoAdminRequired] #[PublicPage] + #[RequireDocumentBaseVersionEtag] #[RequireDocumentSession] public function sync(string $token, int $version = 0): DataResponse { return $this->apiService->sync($this->getSession(), $this->getDocument(), $version, $token); @@ -106,6 +109,7 @@ public function sync(string $token, int $version = 0): DataResponse { #[NoAdminRequired] #[PublicPage] + #[RequireDocumentBaseVersionEtag] #[RequireDocumentSession] public function save(string $token, int $version = 0, ?string $autosaveContent = null, ?string $documentState = null, bool $force = false, bool $manualSave = false): DataResponse { return $this->apiService->save($this->getSession(), $this->getDocument(), $version, $autosaveContent, $documentState, $force, $manualSave, $token); diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index 09b056da07d..19b241d0ce5 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -25,6 +25,7 @@ namespace OCA\Text\Controller; +use OCA\Text\Middleware\Attribute\RequireDocumentBaseVersionEtag; use OCA\Text\Middleware\Attribute\RequireDocumentSession; use OCA\Text\Service\ApiService; use OCA\Text\Service\NotificationService; @@ -69,6 +70,7 @@ public function close(int $documentId, int $sessionId, string $sessionToken): Da #[NoAdminRequired] #[PublicPage] + #[RequireDocumentBaseVersionEtag] #[RequireDocumentSession] public function push(int $version, array $steps, string $awareness): DataResponse { try { @@ -81,6 +83,7 @@ public function push(int $version, array $steps, string $awareness): DataRespons #[NoAdminRequired] #[PublicPage] + #[RequireDocumentBaseVersionEtag] #[RequireDocumentSession] public function sync(int $version = 0): DataResponse { try { @@ -93,6 +96,7 @@ public function sync(int $version = 0): DataResponse { #[NoAdminRequired] #[PublicPage] + #[RequireDocumentBaseVersionEtag] #[RequireDocumentSession] public function save(int $version = 0, ?string $autosaveContent = null, ?string $documentState = null, bool $force = false, bool $manualSave = false): DataResponse { try { diff --git a/lib/Exception/InvalidDocumentBaseVersionEtagException.php b/lib/Exception/InvalidDocumentBaseVersionEtagException.php new file mode 100644 index 00000000000..1bb13ca65a2 --- /dev/null +++ b/lib/Exception/InvalidDocumentBaseVersionEtagException.php @@ -0,0 +1,9 @@ +getAttributes(RequireDocumentBaseVersionEtag::class))) { + $this->assertDocumentBaseVersionEtag(); + } + if (!empty($reflectionMethod->getAttributes(RequireDocumentSession::class))) { $this->assertDocumentSession($controller); } } + /** + * @throws InvalidDocumentBaseVersionEtagException + */ + private function assertDocumentBaseVersionEtag(): void { + $documentId = (int)$this->request->getParam('documentId'); + $baseVersionEtag = $this->request->getParam('baseVersionEtag'); + + $document = $this->documentService->getDocument($documentId); + if ($baseVersionEtag && $document?->getBaseVersionEtag() !== $baseVersionEtag) { + throw new InvalidDocumentBaseVersionEtagException(); + } + } + /** * @throws InvalidSessionException */ @@ -118,9 +141,13 @@ private function assertUserOrShareToken(ISessionAwareController $controller): vo $controller->setDocument($document); } - public function afterException($controller, $methodName, \Exception $exception): DataResponse|Response { + public function afterException($controller, $methodName, \Exception $exception): JSONResponse|Response { + if ($exception instanceof InvalidDocumentBaseVersionEtagException) { + return new JSONResponse($this->l10n->t('Editing session has expired. Please reload the page.'), Http::STATUS_PRECONDITION_FAILED); + } + if ($exception instanceof InvalidSessionException) { - return new DataResponse([], 403); + return new JSONResponse([], 403); } return parent::afterException($controller, $methodName, $exception); diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php index 95dc3310215..1be3bf51d97 100644 --- a/lib/Service/ApiService.php +++ b/lib/Service/ApiService.php @@ -116,7 +116,7 @@ public function create(?int $fileId = null, ?string $filePath = null, ?string $b $document = $this->documentService->getDocument($file->getId()); $freshSession = $document === null; if ($baseVersionEtag && $baseVersionEtag !== $document?->getBaseVersionEtag()) { - return new DataResponse($this->l10n->t('Editing session has expired. Please reload the page.'), Http::STATUS_CONFLICT); + return new DataResponse($this->l10n->t('Editing session has expired. Please reload the page.'), Http::STATUS_PRECONDITION_FAILED); } if ($freshSession) { @@ -193,7 +193,7 @@ public function push(Session $session, Document $document, int $version, array $ $session = $this->sessionService->updateSessionAwareness($session, $awareness); } catch (DoesNotExistException $e) { // Session was removed in the meantime. #3875 - return new DataResponse([], 403); + return new DataResponse($this->l10n->t('Editing session has expired. Please reload the page.'), Http::STATUS_PRECONDITION_FAILED); } if (empty($steps)) { return new DataResponse([]); @@ -204,7 +204,7 @@ public function push(Session $session, Document $document, int $version, array $ return new DataResponse($e->getMessage(), 422); } catch (DoesNotExistException|NotPermittedException) { // Either no write access or session was removed in the meantime (#3875). - return new DataResponse([], 403); + return new DataResponse($this->l10n->t('Editing session has expired. Please reload the page.'), Http::STATUS_PRECONDITION_FAILED); } return new DataResponse($result); } diff --git a/src/services/PollingBackend.js b/src/services/PollingBackend.js index 39595db05cd..3cbb3537f5e 100644 --- a/src/services/PollingBackend.js +++ b/src/services/PollingBackend.js @@ -170,6 +170,9 @@ class PollingBackend { outsideChange: e.response.data.outsideChange, }, }) + } else if (e.response.status === 412) { + this.#syncService.emit('error', { type: ERROR_TYPE.LOAD_ERROR, data: e.response }) + this.disconnect() } else if (e.response.status === 403) { this.#syncService.emit('error', { type: ERROR_TYPE.SOURCE_NOT_FOUND, data: {} }) this.disconnect() diff --git a/src/services/SessionApi.js b/src/services/SessionApi.js index 96bf299ad0a..c8f49e0a77b 100644 --- a/src/services/SessionApi.js +++ b/src/services/SessionApi.js @@ -114,6 +114,7 @@ export class Connection { return this.#post(this.#url(`session/${this.#document.id}/sync`), { ...this.#defaultParams, filePath: this.#options.filePath, + baseVersionEtag: this.#document.baseVersionEtag, version, }) } @@ -122,6 +123,7 @@ export class Connection { return this.#post(this.#url(`session/${this.#document.id}/save`), { ...this.#defaultParams, filePath: this.#options.filePath, + baseVersionEtag: this.#document.baseVersionEtag, version, autosaveContent, documentState, @@ -134,6 +136,7 @@ export class Connection { return this.#post(this.#url(`session/${this.#document.id}/push`), { ...this.#defaultParams, filePath: this.#options.filePath, + baseVersionEtag: this.#document.baseVersionEtag, steps, version, awareness, diff --git a/src/services/SyncService.js b/src/services/SyncService.js index 3c753b3624b..0bdd707ca25 100644 --- a/src/services/SyncService.js +++ b/src/services/SyncService.js @@ -179,7 +179,9 @@ class SyncService { if (!response || code === 'ECONNABORTED') { this.emit('error', { type: ERROR_TYPE.CONNECTION_FAILED, data: {} }) } - if (response?.status === 403) { + if (response?.status === 412) { + this.emit('error', { type: ERROR_TYPE.LOAD_ERROR, data: response }) + } else if (response?.status === 403) { if (!data.document) { // either the session is invalid or the document is read only. logger.error('failed to write to document - not allowed') From f3439d708d38da3398a7d5074cff4a0320cbd82d Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 18 Mar 2024 19:58:16 +0100 Subject: [PATCH 3/6] fix(DocumentStatus): Refactor and migrate to `NcNoteCard` Fixes: #4905 Signed-off-by: Jonas --- cypress/e2e/conflict.spec.js | 3 +- cypress/e2e/share.spec.js | 2 +- cypress/e2e/sync.spec.js | 8 +-- src/components/Editor/DocumentStatus.vue | 85 +++++++++++------------- 4 files changed, 47 insertions(+), 51 deletions(-) diff --git a/cypress/e2e/conflict.spec.js b/cypress/e2e/conflict.spec.js index 9e5021dc6c1..1396f6f9bef 100644 --- a/cypress/e2e/conflict.spec.js +++ b/cypress/e2e/conflict.spec.js @@ -54,7 +54,8 @@ variants.forEach(function({ fixture, mime }) { cy.get('#viewer .modal-header button.header-close').click() cy.get('#viewer').should('not.exist') cy.openFile(fileName) - cy.get('.text-editor .document-status .icon-error') + cy.get('.text-editor .document-status') + .should('contain', 'Document has been changed outside of the editor.') getWrapper() .find('#read-only-editor') .should('contain', 'Hello world') diff --git a/cypress/e2e/share.spec.js b/cypress/e2e/share.spec.js index 5eb124baf8c..e8282d5b775 100644 --- a/cypress/e2e/share.spec.js +++ b/cypress/e2e/share.spec.js @@ -151,7 +151,7 @@ describe('Open test.md in viewer', function() { cy.login(recipient) cy.visit('/apps/files') cy.openFile('test.md') - cy.getModal().find('.empty-content__name').should('contain', 'Failed to load file') + cy.getModal().find('.document-status').should('contain', 'This file cannot be displayed as download is disabled by the share') cy.getModal().getContent().should('not.exist') }) }) diff --git a/cypress/e2e/sync.spec.js b/cypress/e2e/sync.spec.js index 01a68ab7035..582157cff3a 100644 --- a/cypress/e2e/sync.spec.js +++ b/cypress/e2e/sync.spec.js @@ -74,7 +74,7 @@ describe('Sync', () => { }).as('sessionRequests') cy.wait('@dead', { timeout: 30000 }) cy.get('#editor-container .document-status', { timeout: 30000 }) - .should('contain', 'File could not be loaded') + .should('contain', 'Document could not be loaded.') .then(() => { reconnect = true }) @@ -83,7 +83,7 @@ describe('Sync', () => { .as('syncAfterRecovery') cy.wait('@syncAfterRecovery', { timeout: 30000 }) cy.get('#editor-container .document-status', { timeout: 30000 }) - .should('not.contain', 'File could not be loaded') + .should('not.contain', 'Document could not be loaded.') // FIXME: There seems to be a bug where typed words maybe lost if not waiting for the new session cy.wait('@syncAfterRecovery', { timeout: 10000 }) cy.getContent().type('* more content added after the lost connection{enter}') @@ -109,12 +109,12 @@ describe('Sync', () => { cy.wait('@sessionRequests', { timeout: 30000 }) cy.get('#editor-container .document-status', { timeout: 30000 }) - .should('contain', 'File could not be loaded') + .should('contain', 'Document could not be loaded.') cy.wait('@syncAfterRecovery', { timeout: 60000 }) cy.get('#editor-container .document-status', { timeout: 30000 }) - .should('not.contain', 'File could not be loaded') + .should('not.contain', 'Document could not be loaded.') // FIXME: There seems to be a bug where typed words maybe lost if not waiting for the new session cy.wait('@syncAfterRecovery', { timeout: 10000 }) cy.getContent().type('* more content added after the lost connection{enter}') diff --git a/src/components/Editor/DocumentStatus.vue b/src/components/Editor/DocumentStatus.vue index 3782b765197..014ac7a25ee 100644 --- a/src/components/Editor/DocumentStatus.vue +++ b/src/components/Editor/DocumentStatus.vue @@ -22,27 +22,34 @@