diff --git a/CHANGELOG.md b/CHANGELOG.md index 7248d955..b9815d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `markdown.marp.exportAutoOpen` setting ([#464](https://github.com/marp-team/marp-vscode/pull/464) by [@rtfmkiesel](https://github.com/rtfmkiesel)) - Experimental `markdown.marp.pptx.editable` setting ([#489](https://github.com/marp-team/marp-vscode/pull/489)) +- Implement observer to notify about incompatible extensions ([#82](https://github.com/marp-team/marp-vscode/issues/82), [#453](https://github.com/marp-team/marp-vscode/issues/453), [#459](https://github.com/marp-team/marp-vscode/issues/459), [#488](https://github.com/marp-team/marp-vscode/pull/488)) ### Changed diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts index c4910433..5c328b37 100644 --- a/src/__mocks__/vscode.ts +++ b/src/__mocks__/vscode.ts @@ -214,6 +214,10 @@ export class Memento { } } +export class TabInputWebview { + constructor(public readonly viewType: string) {} +} + export class Uri extends URI { static joinPath(uri: Uri, ...pathSegments: string[]) { return Utils.joinPath(uri, ...pathSegments) @@ -236,6 +240,10 @@ export const window = { showTextDocument: jest.fn(async () => ({})), showWarningMessage: jest.fn(async () => undefined), withProgress: jest.fn(), + visibleTextEditors: [], + tabGroups: { + all: [], + }, } export const workspace = { diff --git a/src/extension.test.ts b/src/extension.test.ts index 8b0c2f55..cc2df569 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -9,6 +9,9 @@ import { Memento, Uri, commands, window, workspace, env } from 'vscode' jest.mock('node-fetch') jest.mock('vscode') +jest.mock('./observer', () => ({ + incompatiblePreviewExtensionsObserver: jest.fn(), +})) let themes: (typeof import('./themes'))['default'] diff --git a/src/extension.ts b/src/extension.ts index 4b123de5..89797de3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import * as showQuickPick from './commands/show-quick-pick' import * as toggleMarpFeature from './commands/toggle-marp-feature' import diagnostics from './diagnostics/' import languageProvider from './language/' +import { incompatiblePreviewExtensionsObserver } from './observer' import { marpCoreOptionForPreview, clearMarpCoreOptionCache } from './option' import customTheme from './plugins/custom-theme' import lineNumber from './plugins/line-number' @@ -147,6 +148,7 @@ export const activate = ({ subscriptions }: ExtensionContext) => { } }), workspace.onDidGrantWorkspaceTrust(applyRefreshedConfiguration), + incompatiblePreviewExtensionsObserver(), ) return { extendMarkdownIt } diff --git a/src/observer.test.ts b/src/observer.test.ts new file mode 100644 index 00000000..d5ec297b --- /dev/null +++ b/src/observer.test.ts @@ -0,0 +1,152 @@ +import { commands, window, TabInputWebview } from 'vscode' +import * as observer from './observer' + +jest.mock('vscode') + +beforeEach(() => { + jest.useFakeTimers() + + window.visibleTextEditors = [] + ;(window as any).tabGroups = { all: [] } +}) + +afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() +}) + +describe('Incompatible preview extensions observer', () => { + const document = { + languageId: 'markdown', + getText: () => '---\nmarp: true\n---', + uri: '/test/document', + } + const editor = { document, viewColumn: 0 } as any + const mpeTab = { + input: new TabInputWebview( + // Markdown Preview Enhanced + 'mainThreadWebview-markdown-preview-enhanced', + ), + } + + it('returns ViewObserver', async () => { + const instance = observer.incompatiblePreviewExtensionsObserver() + expect(instance).toBeInstanceOf(observer.ViewObserver) + expect(instance.state).toStrictEqual({ + marpDocument: { + opening: false, + editor: null, + }, + incompatiblePreview: { + opening: false, + type: null, + }, + }) + }) + + it('runs tick function every 1000ms', () => { + const tickMock = jest + .spyOn(observer.ViewObserver.prototype as any, 'tick') + .mockImplementation() + + try { + const instance = observer.incompatiblePreviewExtensionsObserver() + expect(instance).toBeInstanceOf(observer.ViewObserver) + expect(tickMock).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(999) + expect(tickMock).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(1) + expect(tickMock).toHaveBeenCalledTimes(2) + + jest.advanceTimersByTime(1500) + expect(tickMock).toHaveBeenCalledTimes(3) + + // Reset tick interval when restarted + instance.start() + jest.advanceTimersByTime(999) + expect(tickMock).toHaveBeenCalledTimes(4) // Tick was only once called after restart + + // Stop observer + instance.dispose() + jest.advanceTimersByTime(3000) + expect(tickMock).toHaveBeenCalledTimes(4) + } finally { + tickMock.mockRestore() + } + }) + + it('shows notification message when Marp document is opening and incompatible preview is detected', async () => { + window.visibleTextEditors = [editor] + ;(window as any).tabGroups.all = [{ tabs: [mpeTab] }] + ;(window as any).showWarningMessage.mockResolvedValue( + observer.OPEN_MARKDOWN_PREVIEW_BY_VS_CODE, + ) + + const instance = observer.incompatiblePreviewExtensionsObserver() + expect(instance.state).toStrictEqual({ + marpDocument: { opening: true, editor }, + incompatiblePreview: { opening: true, type: 'markdown-preview-enhanced' }, + }) + + expect(window.showWarningMessage).toHaveBeenCalledWith( + `The Markdown preview provided by Markdown Preview Enhanced extension is not compatible with Marp. To preview Marp slide, please open the Markdown preview provided by VS Code.`, + observer.OPEN_MARKDOWN_PREVIEW_BY_VS_CODE, + observer.DONT_NOTIFY_AGAIN, + ) + await (window.showWarningMessage as any).mock.results[0].value + + expect(window.showTextDocument).toHaveBeenCalledWith( + document, + editor.viewColumn, + ) + await (window.showTextDocument as any).mock.results[0].value + + expect(commands.executeCommand).toHaveBeenCalledWith( + 'markdown.showPreviewToSide', + document.uri, + ) + + // It does not emit notification again + jest.advanceTimersByTime(1000) + expect(window.showWarningMessage).toHaveBeenCalledTimes(1) + }) + + it('does not show notification message again when it was closed with "DONT_NOTIFY_AGAIN"', async () => { + window.visibleTextEditors = [editor] + ;(window as any).tabGroups.all = [{ tabs: [mpeTab] }] + ;(window as any).showWarningMessage.mockResolvedValue( + observer.DONT_NOTIFY_AGAIN, + ) + + const instance = observer.incompatiblePreviewExtensionsObserver() + expect(instance.state).toStrictEqual({ + marpDocument: { opening: true, editor }, + incompatiblePreview: { opening: true, type: 'markdown-preview-enhanced' }, + }) + + expect(window.showWarningMessage).toHaveBeenCalledTimes(1) + await (window.showWarningMessage as any).mock.results[0].value + + // Hide text editor once + window.visibleTextEditors = [] + jest.advanceTimersByTime(1000) + + expect(instance.state).toStrictEqual({ + marpDocument: { opening: false, editor: null }, + incompatiblePreview: { opening: true, type: 'markdown-preview-enhanced' }, + }) + expect(window.showWarningMessage).toHaveBeenCalledTimes(1) + + // Show text editor again, but it does not emit notification + window.visibleTextEditors = [editor] + jest.advanceTimersByTime(1000) + + expect(instance.state).toStrictEqual({ + marpDocument: { opening: true, editor }, + incompatiblePreview: { opening: true, type: 'markdown-preview-enhanced' }, + }) + expect(window.showWarningMessage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/observer.ts b/src/observer.ts new file mode 100644 index 00000000..bd868c8f --- /dev/null +++ b/src/observer.ts @@ -0,0 +1,179 @@ +import { EventEmitter } from 'node:events' +import TypedEmitter from 'typed-emitter' +import { TabInputWebview, commands, window } from 'vscode' +import type { Disposable, TextEditor } from 'vscode' +import { detectMarpDocument } from './utils' + +type IncompatiblePreviewType = 'markdown-preview-enhanced' + +const providedBy = { + 'markdown-preview-enhanced': 'Markdown Preview Enhanced extension', +} as const satisfies Record + +interface ViewObserverState { + marpDocument: { + opening: boolean + editor: TextEditor | null + } + incompatiblePreview: { + opening: boolean + type: IncompatiblePreviewType | null + } +} + +interface ViewObserverEventHandler { + observer: ViewObserver +} + +interface ViewObserverChangeStateEventHandler extends ViewObserverEventHandler { + state: ViewObserverState +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type ViewObserverEvents = { + change: (event: ViewObserverChangeStateEventHandler) => void + start: (event: ViewObserverEventHandler) => void + stop: (event: ViewObserverEventHandler) => void +} + +export class ViewObserver + extends (EventEmitter as new () => TypedEmitter) + implements Disposable +{ + #active = false + #interval: number + #timer: NodeJS.Timeout | null = null + #state: ViewObserverState | null = null + + constructor(interval: number) { + super() + + this.#interval = interval + } + + get state() { + return this.#state + } + + start() { + if (this.#active) this.dispose() + + this.emit('start', { observer: this }) + this.#active = true + this.#timer = setInterval(() => this.tick(), this.#interval) + this.tick() + + return this + } + + dispose() { + this.#active = false + this.#state = null + + if (this.#timer) { + clearInterval(this.#timer) + this.#timer = null + } + + this.emit('stop', { observer: this }) + } + + private tick() { + const openingMarpEditor = (() => { + for (const textEditor of window.visibleTextEditors) { + if (detectMarpDocument(textEditor.document)) return textEditor + } + return null + })() + + const incompatiblePreview = ((): IncompatiblePreviewType | null => { + for (const tabGroup of window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + if (tab.input instanceof TabInputWebview) { + // Detect webview provided by supported extensions + if (tab.input.viewType.includes('markdown-preview-enhanced')) + return 'markdown-preview-enhanced' + } + } + } + return null + })() + + // Detect changes + const newState: ViewObserverState = { + marpDocument: { + opening: !!openingMarpEditor, + editor: openingMarpEditor, + }, + incompatiblePreview: { + opening: !!incompatiblePreview, + type: incompatiblePreview, + }, + } + + if ( + !this.#state || + this.#state.marpDocument.opening !== newState.marpDocument.opening || + this.#state.incompatiblePreview.opening !== + newState.incompatiblePreview.opening + ) { + this.emit('change', { observer: this, state: newState }) + } + + this.#state = newState + } +} + +export const OPEN_MARKDOWN_PREVIEW_BY_VS_CODE = + 'Open Markdown preview by VS Code' + +export const DONT_NOTIFY_AGAIN = "Don't notify again" + +export const incompatiblePreviewExtensionsObserver = () => { + const observer = new ViewObserver(1000) + + let shouldNotify = true + + observer.addListener('change', ({ state }) => { + if ( + state.marpDocument.opening && + state.incompatiblePreview.opening && + shouldNotify + ) { + const provided = + state.incompatiblePreview.type === null + ? '' + : ` provided by ${providedBy[state.incompatiblePreview.type]}` + + window + .showWarningMessage( + `The Markdown preview${provided} is not compatible with Marp. To preview Marp slide, please open the Markdown preview provided by VS Code.`, + OPEN_MARKDOWN_PREVIEW_BY_VS_CODE, + DONT_NOTIFY_AGAIN, + ) + .then(async (selected) => { + if ( + selected === OPEN_MARKDOWN_PREVIEW_BY_VS_CODE && + state.marpDocument.editor + ) { + if (window.visibleTextEditors.includes(state.marpDocument.editor)) { + await window.showTextDocument( + state.marpDocument.editor.document, + state.marpDocument.editor.viewColumn, + ) + } + await commands.executeCommand( + 'markdown.showPreviewToSide', + state.marpDocument.editor.document.uri, + ) + } else if (selected === DONT_NOTIFY_AGAIN) { + shouldNotify = false + } + }) + } + }) + + observer.start() + + return observer +}