-
-
Notifications
You must be signed in to change notification settings - Fork 82
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #488 from marp-team/incompatible-extensions-observer
Implement observer to notify about incompatible extensions
- Loading branch information
Showing
6 changed files
with
345 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<IncompatiblePreviewType, string> | ||
|
||
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<ViewObserverEvents>) | ||
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 | ||
} |