Skip to content

Commit

Permalink
Merge pull request #488 from marp-team/incompatible-extensions-observer
Browse files Browse the repository at this point in the history
Implement observer to notify about incompatible extensions
  • Loading branch information
yhatt authored Jan 23, 2025
2 parents d1e8538 + 0f2932a commit 1f00a32
Show file tree
Hide file tree
Showing 6 changed files with 345 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = {
Expand Down
3 changes: 3 additions & 0 deletions src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -147,6 +148,7 @@ export const activate = ({ subscriptions }: ExtensionContext) => {
}
}),
workspace.onDidGrantWorkspaceTrust(applyRefreshedConfiguration),
incompatiblePreviewExtensionsObserver(),
)

return { extendMarkdownIt }
Expand Down
152 changes: 152 additions & 0 deletions src/observer.test.ts
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)
})
})
179 changes: 179 additions & 0 deletions src/observer.ts
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
}

0 comments on commit 1f00a32

Please sign in to comment.