From c1d2babd1def39c132eff9dceae235ee8f3ff4a1 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 6 Aug 2026 16:34:13 +0200 Subject: [PATCH] regiters markdown editor commands as vscode commands, so that shortcuts can be customized --- .github/workflows/pr.yml | 5 +- AGENTS.md | 2 + extensions/esbuild-common.mts | 44 +- .../CONTRIBUTING.md | 4 + .../esbuild.browser.mts | 2 + .../esbuild.markdownEditor.mts | 15 + .../markdown-language-features/esbuild.mts | 2 + .../markdown-editor-src/editor.ts | 37 +- .../markdown-language-features/package.json | 861 ++++++++++++++++++ .../updateMarkdownEditorPackageJson.mts | 290 ++++++ .../updateMarkdownEditorPackageJson.test.mts | 146 +++ .../src/extension.shared.ts | 23 + .../src/preview/markdownEditorProvider.ts | 36 + package.json | 1 + 14 files changed, 1454 insertions(+), 14 deletions(-) create mode 100644 extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.mts create mode 100644 extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.test.mts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1ee318fea93..63e7d7e7761 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -82,6 +82,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check Markdown editor package.json + run: npm run markdown-editor-package-json-check + - name: Compile & Hygiene run: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts env: @@ -425,5 +428,3 @@ jobs: - name: Run Completions Core lib tests using VS Code working-directory: extensions/copilot run: npm run test:completions-core - - diff --git a/AGENTS.md b/AGENTS.md index d6abb76ab83..57cb1776ae7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,5 @@ This file provides instructions for AI coding agents working with the VS Code codebase. For detailed project overview, architecture, coding guidelines, and validation steps, see the [Copilot Instructions](.github/copilot-instructions.md). + +This short paragraph demonstrates a simple Markdown documentation update. diff --git a/extensions/esbuild-common.mts b/extensions/esbuild-common.mts index 0f945940295..a0ce75a2bd3 100644 --- a/extensions/esbuild-common.mts +++ b/extensions/esbuild-common.mts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { stat } from 'node:fs/promises'; import path from 'node:path'; import esbuild from 'esbuild'; @@ -10,6 +11,8 @@ export interface RunConfig { readonly outdir: string; readonly entryPoints: esbuild.BuildOptions['entryPoints']; readonly additionalOptions?: Partial; + readonly additionalWatchPaths?: readonly string[]; + readonly beforeBuild?: () => Promise | unknown; } // `esbuild.stop()` shuts down the single esbuild service shared by all concurrent builds, so we @@ -17,9 +20,13 @@ export interface RunConfig { // service while a sibling build (e.g. running in the same `Promise.all`) is still using it. let pendingBuilds = 0; -async function buildOnce(options: esbuild.BuildOptions): Promise { +async function buildOnce( + options: esbuild.BuildOptions, + beforeBuild?: () => Promise | unknown, +): Promise { pendingBuilds++; try { + await beforeBuild?.(); return await esbuild.build(options); } finally { if (--pendingBuilds === 0) { @@ -54,10 +61,15 @@ export async function runBuild( const isWatch = args.indexOf('--watch') >= 0; if (isWatch) { - await watchWithParcel(resolvedOptions, config.srcDir, () => didBuild?.(outdir)); + await watchWithParcel( + resolvedOptions, + [config.srcDir, ...(config.additionalWatchPaths ?? [])], + config.beforeBuild, + () => didBuild?.(outdir), + ); } else { try { - await buildOnce(resolvedOptions); + await buildOnce(resolvedOptions, config.beforeBuild); await didBuild?.(outdir); } catch { process.exit(1); @@ -66,7 +78,12 @@ export async function runBuild( } // We use @parcel/watcher as it has much lower cpu usage when idle compared to esbuild's watch mode -async function watchWithParcel(options: esbuild.BuildOptions, srcDir: string, didBuild?: () => Promise | unknown): Promise { +async function watchWithParcel( + options: esbuild.BuildOptions, + watchPaths: readonly string[], + beforeBuild?: () => Promise | unknown, + didBuild?: () => Promise | unknown, +): Promise { let debounce: ReturnType | undefined; const rebuild = () => { if (debounce) { @@ -76,7 +93,7 @@ async function watchWithParcel(options: esbuild.BuildOptions, srcDir: string, di try { // Also instead of retaining the esbuild context, we are re-running the entire build on each change. // This reduces memory usage since most projects don't need to be re-built often. - const result = await buildOnce(options); + const result = await buildOnce(options, beforeBuild); if (result.errors.length === 0) { await didBuild?.(); } @@ -96,10 +113,17 @@ async function watchWithParcel(options: esbuild.BuildOptions, srcDir: string, di const outdirGlob = options.outdir.replace(/\\/g, '/').replace(/\/$/, ''); ignore.push(outdirGlob, `${outdirGlob}/**`); } - await watcher.subscribe(srcDir, (_err, _events) => { - rebuild(); - }, { - ignore - }); + await Promise.all(watchPaths.map(async watchPath => { + const watchPathStat = await stat(watchPath); + const watchedFile = watchPathStat.isDirectory() ? undefined : path.resolve(watchPath); + const watchRoot = watchedFile ? path.dirname(watchedFile) : watchPath; + return watcher.subscribe(watchRoot, (_err, events) => { + if (!watchedFile || events.some(event => path.resolve(event.path) === watchedFile)) { + rebuild(); + } + }, { + ignore + }); + })); rebuild(); } diff --git a/extensions/markdown-language-features/CONTRIBUTING.md b/extensions/markdown-language-features/CONTRIBUTING.md index 07bd63ec41a..89b37d564a3 100644 --- a/extensions/markdown-language-features/CONTRIBUTING.md +++ b/extensions/markdown-language-features/CONTRIBUTING.md @@ -76,6 +76,10 @@ Build outputs are written to `out/` (desktop), `dist/` (web), and `notebook-out/ Launch VS Code with the **Run VS Code** task. After the Markdown editor bundle is rebuilt, reload the development window or close and reopen the Markdown custom editor. +5. **Update editor commands** + + Markdown editor commands and their default keybindings are defined in `vscode-packages/vscode-team-tools/packages/markdown-editor/src/editorCommands.ts`. Do not manually edit entries marked with `"$generated": true` in this extension's `package.json`: `npm run build-markdown-editor` and `npm run watch-markdown-editor` regenerate them while preserving manual entries. Run `npm run check-markdown-editor-package-json` to verify that the checked-in manifest is current without modifying it. + ### Running tests You can run the VS Code extension tests by running the `Markdown Extension Tests` target in VS Code. This will run the tests under `./src/test` diff --git a/extensions/markdown-language-features/esbuild.browser.mts b/extensions/markdown-language-features/esbuild.browser.mts index ece1769bcdc..e2aef5d3a5d 100644 --- a/extensions/markdown-language-features/esbuild.browser.mts +++ b/extensions/markdown-language-features/esbuild.browser.mts @@ -5,6 +5,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { run } from '../esbuild-extension-common.mts'; +import { updatePackageJsonFile } from './scripts/updateMarkdownEditorPackageJson.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); @@ -25,6 +26,7 @@ run({ }, srcDir, outdir: outDir, + beforeBuild: () => updatePackageJsonFile('write'), additionalOptions: { tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, diff --git a/extensions/markdown-language-features/esbuild.markdownEditor.mts b/extensions/markdown-language-features/esbuild.markdownEditor.mts index bfd86f4923f..7fa529db792 100644 --- a/extensions/markdown-language-features/esbuild.markdownEditor.mts +++ b/extensions/markdown-language-features/esbuild.markdownEditor.mts @@ -2,11 +2,21 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { stat } from 'node:fs/promises'; import path from 'path'; +import { fileURLToPath } from 'url'; import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'markdown-editor-src'); const outDir = path.join(import.meta.dirname, 'markdown-editor-out'); +const updatePackageJsonModuleUrl = new URL('./scripts/updateMarkdownEditorPackageJson.mts', import.meta.url); +const updatePackageJsonModulePath = fileURLToPath(updatePackageJsonModuleUrl); + +async function updateMarkdownEditorPackageJsonFile(): Promise { + const version = (await stat(updatePackageJsonModulePath)).mtimeMs; + const module = await import(`${updatePackageJsonModuleUrl.href}?version=${version}`) as typeof import('./scripts/updateMarkdownEditorPackageJson.mts'); + return module.updatePackageJsonFile('write'); +} run({ entryPoints: [ @@ -14,6 +24,11 @@ run({ ], srcDir, outdir: outDir, + additionalWatchPaths: [ + path.dirname(fileURLToPath(import.meta.resolve('@vscode/markdown-editor/commands'))), + updatePackageJsonModulePath, + ], + beforeBuild: updateMarkdownEditorPackageJsonFile, additionalOptions: { // `@vscode/diff` has a Node-only code path that dynamically imports // `node:fs/promises` (guarded by a `process.versions.node` check). It is diff --git a/extensions/markdown-language-features/esbuild.mts b/extensions/markdown-language-features/esbuild.mts index 2a7eda8c183..d183409a509 100644 --- a/extensions/markdown-language-features/esbuild.mts +++ b/extensions/markdown-language-features/esbuild.mts @@ -5,6 +5,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { run } from '../esbuild-extension-common.mts'; +import { updatePackageJsonFile } from './scripts/updateMarkdownEditorPackageJson.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); @@ -25,4 +26,5 @@ run({ }, srcDir, outdir: outDir, + beforeBuild: () => updatePackageJsonFile('write'), }, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index 56e1b852d60..c7f25e0d4a8 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CommentModeController, CommentsModel, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringReplacement, StringValue, VsCodeV2CommentsView, findNodeOffsetById, taskCheckboxRange, type CodeBlockAstNode } from '@vscode/markdown-editor'; +import { CommentModeController, CommentsModel, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringReplacement, StringValue, VsCodeV2CommentsView, commands, findNodeOffsetById, taskCheckboxRange, vscodeHostKeyboardProfile, vscodeLocalKeyboardProfile, type CodeBlockAstNode } from '@vscode/markdown-editor'; import { Disposable, autorun, observableValue } from '@vscode/markdown-editor/observables'; import { VirtualizedIframeEmbeddedEditorFactory, type IframeEmbeddedEditorProvider, type IframeEmbeddedEditorProviderSelector, type ResolvedIframeEmbeddedEditor } from '@vscode/markdown-editor/web-editors'; import mermaid from 'mermaid'; @@ -48,6 +48,7 @@ class Editor extends Disposable { #codeBlockEditorProviders: readonly CodeBlockEditorProviderDefinition[] = []; #nextCodeBlockEditorRequestId = 1; readonly #codeBlockEditorRequests = new Map void>(); + #controller: EditorController | undefined; #view: EditorView | undefined; #embeddedCodeEditorFactory: VirtualizedIframeEmbeddedEditorFactory | undefined; @@ -131,6 +132,13 @@ class Editor extends Disposable { this.#commentsView?.revealComment(message.id); break; } + case 'command': { + const command = commands.find(command => command.id === message.command); + if (command) { + this.#controller?.executeCommand(command); + } + break; + } } }); @@ -232,13 +240,38 @@ class Editor extends Disposable { // backing TextDocument's own undo stack. `record` is deliberately omitted: // the TextDocument owns the history, and a second local stack would drift // from the Edit menu, dirty state and hot exit. - this._register(new EditorController(model, view, { + this.#controller = this._register(new EditorController(model, view, { + keyboardProfile: vscodeLocalKeyboardProfile, + forwardedKeyboardProfile: vscodeHostKeyboardProfile, historyStrategy: { undo: () => this.#vscode.postMessage({ type: 'history', command: 'undo' }), redo: () => this.#vscode.postMessage({ type: 'history', command: 'redo' }), }, })); + let lastEditorFocus: boolean | undefined; + const postEditorFocus = (): void => { + const focused = document.hasFocus() && document.activeElement === view.element; + if (focused === lastEditorFocus) { + return; + } + lastEditorFocus = focused; + this.#vscode.postMessage({ type: 'editorFocusChanged', focused }); + }; + const onFocusOut = (): void => queueMicrotask(postEditorFocus); + document.addEventListener('focusin', postEditorFocus); + document.addEventListener('focusout', onFocusOut); + window.addEventListener('focus', postEditorFocus); + window.addEventListener('blur', postEditorFocus); + this._register({ + dispose: () => { + document.removeEventListener('focusin', postEditorFocus); + document.removeEventListener('focusout', onFocusOut); + window.removeEventListener('focus', postEditorFocus); + window.removeEventListener('blur', postEditorFocus); + }, + }); host.appendChild(view.element); + postEditorFocus(); // Render comments as the VS Code V2 markdown cards. The card colours come // from the webview's own `--vscode-*` theme variables; `theme` only picks diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index b27eaea1f78..0adc706d747 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -218,6 +218,279 @@ "title": "%markdown.editor.insertImageFromWorkspace%", "category": "Markdown", "enablement": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !activeEditorIsReadonly" + }, + { + "command": "markdown.editor.cursorLeft", + "title": "Move Cursor Left", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorRight", + "title": "Move Cursor Right", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorUp", + "title": "Move Cursor Up", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorDown", + "title": "Move Cursor Down", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorLeftSelect", + "title": "Select Left", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorRightSelect", + "title": "Select Right", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorUpSelect", + "title": "Select Up", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorDownSelect", + "title": "Select Down", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeft", + "title": "Move Cursor Word Left", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRight", + "title": "Move Cursor Word Right", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeftSelect", + "title": "Select Word Left", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRightSelect", + "title": "Select Word Right", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStart", + "title": "Move Cursor to Visual Line Start", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEnd", + "title": "Move Cursor to Visual Line End", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStartSelect", + "title": "Select to Visual Line Start", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEndSelect", + "title": "Select to Visual Line End", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineStart", + "title": "Move Cursor to Logical Line Start", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineEnd", + "title": "Move Cursor to Logical Line End", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineStartSelect", + "title": "Select to Logical Line Start", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineEndSelect", + "title": "Select to Logical Line End", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStart", + "title": "Move Cursor to Document Start", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEnd", + "title": "Move Cursor to Document End", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStartSelect", + "title": "Select to Document Start", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEndSelect", + "title": "Select to Document End", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.selectAll", + "title": "Select All", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.deleteLeft", + "title": "Delete Left", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.deleteRight", + "title": "Delete Right", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordLeft", + "title": "Delete Word Left", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordRight", + "title": "Delete Word Right", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.deleteLineLeft", + "title": "Delete All Left", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.deleteLineRight", + "title": "Delete All Right", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.undo", + "title": "Undo", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.redo", + "title": "Redo", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.insertTab", + "title": "Insert Tab", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.outdent", + "title": "Outdent", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.toggleTabFocus", + "title": "Toggle Tab Key Moves Focus", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.smartEnter", + "title": "Insert Paragraph", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.insertHardLineBreak", + "title": "Insert Hard Line Break", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true + }, + { + "command": "markdown.editor.insertParagraph", + "title": "Insert Paragraph Without Continuing Markup", + "category": "Markdown Editor", + "enablement": "activeCustomEditorId == 'vscode.markdown.editor'", + "$generated": true } ], "menus": { @@ -380,6 +653,201 @@ { "command": "markdown.togglePreview", "when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/" + }, + { + "command": "markdown.editor.cursorLeft", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorRight", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorUp", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorDown", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorLeftSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorRightSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorUpSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorDownSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeft", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRight", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeftSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRightSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStart", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEnd", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStartSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEndSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineStart", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineEnd", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineStartSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineEndSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStart", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEnd", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStartSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEndSelect", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.selectAll", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.deleteLeft", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.deleteRight", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordLeft", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordRight", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.deleteLineLeft", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.deleteLineRight", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.undo", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.redo", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.insertTab", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.outdent", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.toggleTabFocus", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.smartEnter", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.insertHardLineBreak", + "when": "false", + "$generated": true + }, + { + "command": "markdown.editor.insertParagraph", + "when": "false", + "$generated": true } ] }, @@ -395,6 +863,396 @@ "key": "shift+ctrl+v", "mac": "shift+cmd+v", "when": "!terminalFocus && ((editorFocus && resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused) || activeCustomEditorId == 'vscode.markdown.preview.editor')" + }, + { + "command": "markdown.editor.cursorLeft", + "key": "ctrl+b", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorLeft", + "key": "left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorRight", + "key": "ctrl+f", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorRight", + "key": "right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorUp", + "key": "ctrl+p", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorUp", + "key": "up", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorDown", + "key": "ctrl+n", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDown", + "key": "down", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorLeftSelect", + "key": "shift+left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorRightSelect", + "key": "shift+right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorUpSelect", + "key": "shift+up", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorDownSelect", + "key": "shift+down", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeft", + "key": "alt+left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeft", + "key": "ctrl+left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRight", + "key": "alt+right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRight", + "key": "ctrl+right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeftSelect", + "key": "shift+alt+left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordLeftSelect", + "key": "ctrl+shift+left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRightSelect", + "key": "shift+alt+right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorWordRightSelect", + "key": "ctrl+shift+right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStart", + "key": "cmd+left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStart", + "key": "home", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEnd", + "key": "cmd+right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEnd", + "key": "end", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStartSelect", + "key": "shift+cmd+left", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineStartSelect", + "key": "shift+home", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEndSelect", + "key": "shift+cmd+right", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorVisualLineEndSelect", + "key": "shift+end", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineStart", + "key": "ctrl+a", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineEnd", + "key": "ctrl+e", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineStartSelect", + "key": "ctrl+shift+a", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorLogicalLineEndSelect", + "key": "ctrl+shift+e", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStart", + "key": "cmd+up", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStart", + "key": "ctrl+home", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEnd", + "key": "cmd+down", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEnd", + "key": "ctrl+end", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStartSelect", + "key": "shift+cmd+up", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentStartSelect", + "key": "ctrl+shift+home", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEndSelect", + "key": "shift+cmd+down", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.cursorDocumentEndSelect", + "key": "ctrl+shift+end", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.selectAll", + "key": "cmd+a", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.selectAll", + "key": "ctrl+a", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteLeft", + "key": "ctrl+h", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteLeft", + "key": "ctrl+backspace", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteLeft", + "key": "backspace", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.deleteLeft", + "key": "shift+backspace", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.deleteRight", + "key": "ctrl+d", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteRight", + "key": "ctrl+delete", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteRight", + "key": "delete", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordLeft", + "key": "alt+backspace", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordLeft", + "key": "ctrl+backspace", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordRight", + "key": "alt+delete", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteWordRight", + "key": "ctrl+delete", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteLineLeft", + "key": "cmd+backspace", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteLineRight", + "key": "cmd+delete", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.deleteLineRight", + "key": "ctrl+k", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.undo", + "key": "cmd+z", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.undo", + "key": "ctrl+z", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.redo", + "key": "shift+cmd+z", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.redo", + "key": "ctrl+shift+z", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.redo", + "key": "ctrl+y", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && !isMac", + "$generated": true + }, + { + "command": "markdown.editor.smartEnter", + "key": "enter", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.insertHardLineBreak", + "key": "shift+enter", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true + }, + { + "command": "markdown.editor.insertParagraph", + "key": "cmd+enter", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac", + "$generated": true + }, + { + "command": "markdown.editor.insertParagraph", + "key": "ctrl+enter", + "when": "activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus", + "$generated": true } ], "configuration": [ @@ -897,6 +1755,9 @@ "watch-webview": "node ./esbuild.webview.mts --watch", "build-markdown-editor": "node ./esbuild.markdownEditor.mts", "watch-markdown-editor": "node ./esbuild.markdownEditor.mts --watch", + "update-markdown-editor-package-json": "node ./scripts/updateMarkdownEditorPackageJson.mts --write", + "check-markdown-editor-package-json": "node ./scripts/updateMarkdownEditorPackageJson.mts --check", + "test-markdown-editor-package-json": "node --test ./scripts/updateMarkdownEditorPackageJson.test.mts", "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", "bundle-web": "node ./esbuild.browser.mts", "typecheck-web": "node ../../node_modules/@typescript/native/lib/tsc.js --project ./tsconfig.browser.json --noEmit", diff --git a/extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.mts b/extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.mts new file mode 100644 index 00000000000..8d3f744707e --- /dev/null +++ b/extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.mts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { readFile, stat, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; +import type { + EditorCommandDefinition, + EditorCommandKeybinding, + KeyboardPlatform, +} from '@vscode/markdown-editor/commands'; + +const GENERATED_MARKER = '$generated'; +const MARKDOWN_EDITOR_ACTIVE = `activeCustomEditorId == 'vscode.markdown.editor'`; +const MARKDOWN_EDITOR_KEYBINDING = `${MARKDOWN_EDITOR_ACTIVE} && markdownEditorFocus`; +const PACKAGE_JSON_UPDATE_DEBOUNCE_MS = 2_000; + +interface GeneratedItem { + readonly $generated: true; +} + +interface CommandContribution extends GeneratedItem { + readonly command: string; + readonly title: string; + readonly category: string; + readonly enablement: string; +} + +interface KeybindingContribution extends GeneratedItem { + readonly command: string; + readonly key: string; + readonly when: string; +} + +interface CommandPaletteContribution extends GeneratedItem { + readonly command: string; + readonly when: 'false'; +} + +interface PackageJson { + readonly contributes?: { + readonly commands?: readonly Record[]; + readonly keybindings?: readonly Record[]; + readonly menus?: { + readonly commandPalette?: readonly Record[]; + readonly [key: string]: unknown; + }; + readonly [key: string]: unknown; + }; + readonly [key: string]: unknown; +} + +export type PackageJsonUpdate = + | { readonly kind: 'unchanged' } + | { readonly kind: 'updated'; readonly packageJson: PackageJson }; + +export function updatePackageJson( + currentPackageJson: PackageJson, + commandDefinitions: readonly EditorCommandDefinition[], +): PackageJsonUpdate { + const hostCommands = commandDefinitions.filter(command => command.routing !== 'local'); + const generatedCommands: readonly CommandContribution[] = commandDefinitions.map(command => ({ + command: command.id, + title: command.title, + category: 'Markdown Editor', + enablement: MARKDOWN_EDITOR_ACTIVE, + $generated: true, + })); + const generatedKeybindings: readonly KeybindingContribution[] = hostCommands.flatMap(command => + command.keybindings.map(keybinding => ({ + command: command.id, + key: toVsCodeKeybinding(keybinding), + when: combineWhenClauses(MARKDOWN_EDITOR_KEYBINDING, platformWhenClause(keybinding.platforms)), + $generated: true as const, + })) + ); + const generatedCommandPaletteEntries: readonly CommandPaletteContribution[] = commandDefinitions.map(command => ({ + command: command.id, + when: 'false', + $generated: true, + })); + + const contributes = currentPackageJson.contributes ?? {}; + const commands = replaceGeneratedItems( + contributes.commands ?? [], + generatedCommands, + item => String(item.command ?? ''), + 'command', + ); + const keybindings = replaceGeneratedItems( + contributes.keybindings ?? [], + generatedKeybindings, + item => JSON.stringify([item.command, item.key, item.when]), + 'keybinding', + ); + const menus = contributes.menus ?? {}; + const commandPalette = replaceGeneratedItems( + menus.commandPalette ?? [], + generatedCommandPaletteEntries, + item => String(item.command ?? ''), + 'Command Palette entry', + ); + const updatedPackageJson: PackageJson = { + ...currentPackageJson, + contributes: { + ...contributes, + commands, + keybindings, + menus: { + ...menus, + commandPalette, + }, + }, + }; + + return JSON.stringify(updatedPackageJson) === JSON.stringify(currentPackageJson) + ? { kind: 'unchanged' } + : { kind: 'updated', packageJson: updatedPackageJson }; +} + +function replaceGeneratedItems>( + currentItems: readonly Record[], + generatedItems: readonly T[], + identity: (item: Record) => string, + kind: string, +): readonly Record[] { + const firstGeneratedIndex = currentItems.findIndex(isGenerated); + const manualItems = currentItems.filter(item => !isGenerated(item)); + const manualIdentities = new Set(manualItems.map(identity)); + const generatedIdentities = new Set(); + for (const item of generatedItems) { + const itemIdentity = identity(item); + if (generatedIdentities.has(itemIdentity)) { + throw new Error(`Cannot generate duplicate Markdown editor ${kind} '${itemIdentity}'.`); + } + generatedIdentities.add(itemIdentity); + if (manualIdentities.has(itemIdentity)) { + throw new Error(`Cannot generate Markdown editor ${kind} '${itemIdentity}' because a manual entry already exists.`); + } + } + + const insertionIndex = firstGeneratedIndex < 0 + ? manualItems.length + : Math.min(firstGeneratedIndex, manualItems.length); + return [ + ...manualItems.slice(0, insertionIndex), + ...generatedItems, + ...manualItems.slice(insertionIndex), + ]; +} + +function isGenerated(item: Record): boolean { + return item[GENERATED_MARKER] === true; +} + +function toVsCodeKeybinding(binding: EditorCommandKeybinding): string { + const result: string[] = []; + if (binding.modifiers?.ctrl) { result.push('ctrl'); } + if (binding.modifiers?.shift) { result.push('shift'); } + if (binding.modifiers?.alt) { result.push('alt'); } + if (binding.modifiers?.meta) { result.push('cmd'); } + result.push(toVsCodeKey(binding.key)); + return result.join('+'); +} + +function toVsCodeKey(key: string): string { + switch (key) { + case 'ArrowLeft': return 'left'; + case 'ArrowRight': return 'right'; + case 'ArrowUp': return 'up'; + case 'ArrowDown': return 'down'; + default: return key.toLowerCase(); + } +} + +function platformWhenClause(platforms: readonly KeyboardPlatform[] | undefined): string | undefined { + if (!platforms || platforms.length === 3) { + return undefined; + } + const platformClauses = platforms.map(platform => { + switch (platform) { + case 'macos': return 'isMac'; + case 'windows': return 'isWindows'; + case 'linux': return 'isLinux'; + } + }); + if (platformClauses.length === 2 && !platforms.includes('macos')) { + return '!isMac'; + } + return platformClauses.length === 1 + ? platformClauses[0] + : `(${platformClauses.join(' || ')})`; +} + +function combineWhenClauses(...clauses: readonly (string | undefined)[]): string { + return clauses.filter((clause): clause is string => clause !== undefined).join(' && '); +} + +const updatePackageJsonFileDebounced = debounceAsync( + () => updatePackageJsonFileNow('write'), + PACKAGE_JSON_UPDATE_DEBOUNCE_MS, +); + +export function updatePackageJsonFile(mode: 'write' | 'check'): Promise<'unchanged' | 'updated'> { + return mode === 'write' + ? updatePackageJsonFileDebounced() + : updatePackageJsonFileNow(mode); +} + +async function updatePackageJsonFileNow(mode: 'write' | 'check'): Promise<'unchanged' | 'updated'> { + const packageJsonPath = path.resolve(import.meta.dirname, '..', 'package.json'); + const currentText = await readFile(packageJsonPath, 'utf8'); + const currentPackageJson = JSON.parse(currentText) as PackageJson; + const commandDefinitions = await loadCommandDefinitions(); + const update = updatePackageJson(currentPackageJson, commandDefinitions); + if (update.kind === 'unchanged') { + return 'unchanged'; + } + if (mode === 'check') { + throw new Error('package.json is out of date. Run npm run update-markdown-editor-package-json.'); + } + const newline = currentText.includes('\r\n') ? '\r\n' : '\n'; + const updatedText = `${JSON.stringify(update.packageJson, null, 2)}\n`.replaceAll('\n', newline); + await writeFile(packageJsonPath, updatedText); + return 'updated'; +} + +export function debounceAsync(callback: () => Promise, delay: number): () => Promise { + let timeout: ReturnType | undefined; + let pending: { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: unknown) => void; + } | undefined; + + return () => { + if (!pending) { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + pending = { promise, resolve, reject }; + } + + if (timeout) { + clearTimeout(timeout); + } + const current = pending; + timeout = setTimeout(async () => { + timeout = undefined; + pending = undefined; + try { + current.resolve(await callback()); + } catch (error) { + current.reject(error); + } + }, delay); + return current.promise; + }; +} + +async function loadCommandDefinitions(): Promise { + const commandsUrl = import.meta.resolve('@vscode/markdown-editor/commands'); + const commandsPath = fileURLToPath(commandsUrl); + const version = (await stat(commandsPath)).mtimeMs; + const module = await import(`${commandsUrl}?version=${version}`) as { + readonly commands: readonly EditorCommandDefinition[]; + }; + return module.commands; +} + +async function main(): Promise { + const argument = process.argv[2] ?? '--write'; + if (argument !== '--write' && argument !== '--check') { + throw new Error(`Unknown argument '${argument}'. Expected --write or --check.`); + } + const result = await updatePackageJsonFile(argument === '--check' ? 'check' : 'write'); + console.log(`Markdown editor package.json: ${result}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.test.mts b/extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.test.mts new file mode 100644 index 00000000000..3718d6919d2 --- /dev/null +++ b/extensions/markdown-language-features/scripts/updateMarkdownEditorPackageJson.test.mts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { EditorCommandDefinition } from '@vscode/markdown-editor/commands'; +import { debounceAsync, updatePackageJson } from './updateMarkdownEditorPackageJson.mts'; + +const command: EditorCommandDefinition = { + id: 'markdown.editor.cursorLeft', + title: 'Move Cursor Left', + action: { kind: 'cursor', command: 'left', extend: false }, + keybindings: [ + { key: 'ArrowLeft' }, + { key: 'b', modifiers: { ctrl: true }, platforms: ['macos'] }, + ], +}; + +describe('updatePackageJson', () => { + it('replaces stale generated entries and preserves manual entries', () => { + const current = { + name: 'test', + contributes: { + commands: [ + { command: 'manual', title: 'Manual' }, + { command: 'markdown.editor.stale', title: 'Stale', $generated: true }, + ], + menus: { + commandPalette: [ + { command: 'manual', when: 'editorFocus' }, + { command: 'markdown.editor.stale', when: 'false', $generated: true }, + ], + }, + keybindings: [ + { command: 'manual', key: 'f1' }, + { command: 'markdown.editor.stale', key: 'f2', when: 'false', $generated: true }, + ], + }, + }; + + const result = updatePackageJson(current, [command]); + assert.equal(result.kind, 'updated'); + if (result.kind !== 'updated') { + return; + } + assert.deepEqual(result.packageJson.contributes?.commands, [ + { command: 'manual', title: 'Manual' }, + { + command: command.id, + title: command.title, + category: 'Markdown Editor', + enablement: `activeCustomEditorId == 'vscode.markdown.editor'`, + $generated: true, + }, + ]); + assert.deepEqual(result.packageJson.contributes?.menus?.commandPalette, [ + { command: 'manual', when: 'editorFocus' }, + { + command: command.id, + when: 'false', + $generated: true, + }, + ]); + assert.deepEqual(result.packageJson.contributes?.keybindings, [ + { command: 'manual', key: 'f1' }, + { + command: command.id, + key: 'left', + when: `activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus`, + $generated: true, + }, + { + command: command.id, + key: 'ctrl+b', + when: `activeCustomEditorId == 'vscode.markdown.editor' && markdownEditorFocus && isMac`, + $generated: true, + }, + ]); + }); + + it('returns unchanged for current generated entries', () => { + const first = updatePackageJson({ contributes: {} }, [command]); + assert.equal(first.kind, 'updated'); + if (first.kind !== 'updated') { + return; + } + assert.deepEqual(updatePackageJson(first.packageJson, [command]), { kind: 'unchanged' }); + }); + + it('rejects collisions with manual command entries', () => { + assert.throws(() => updatePackageJson({ + contributes: { + commands: [{ command: command.id, title: 'Manual' }], + }, + }, [command]), /manual entry already exists/); + }); + + it('rejects duplicate generated command entries', () => { + assert.throws( + () => updatePackageJson({ contributes: {} }, [command, command]), + /duplicate Markdown editor command/, + ); + }); + + it('registers local commands without generating host keybindings', () => { + const localCommand: EditorCommandDefinition = { + ...command, + id: 'markdown.editor.insertTab', + routing: 'local', + }; + const result = updatePackageJson({ contributes: {} }, [localCommand]); + assert.equal(result.kind, 'updated'); + if (result.kind !== 'updated') { + return; + } + assert.deepEqual(result.packageJson.contributes?.commands, [{ + command: localCommand.id, + title: localCommand.title, + category: 'Markdown Editor', + enablement: `activeCustomEditorId == 'vscode.markdown.editor'`, + $generated: true, + }]); + assert.deepEqual(result.packageJson.contributes?.menus?.commandPalette, [{ + command: localCommand.id, + when: 'false', + $generated: true, + }]); + assert.deepEqual(result.packageJson.contributes?.keybindings, []); + }); +}); + +describe('debounceAsync', () => { + it('combines calls made before the delay elapses', async () => { + let callCount = 0; + const debounced = debounceAsync(async () => ++callCount, 0); + + const first = debounced(); + const second = debounced(); + + assert.equal(first, second); + assert.equal(await first, 1); + assert.equal(callCount, 1); + }); +}); diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index d8ad54ade2f..c9f281c55e4 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -48,6 +48,7 @@ export function activateShared( const markdownEditorProvider = new MarkdownEditorProvider(context.extensionUri, context.globalState, opener, contributions, logger); context.subscriptions.push(markdownEditorProvider); + context.subscriptions.push(registerMarkdownEditorCommands(context, markdownEditorProvider)); context.subscriptions.push(vscode.window.registerCustomEditorProvider( MarkdownEditorProvider.viewType, markdownEditorProvider, @@ -61,6 +62,28 @@ export function activateShared( })); } +function registerMarkdownEditorCommands( + context: vscode.ExtensionContext, + provider: MarkdownEditorProvider, +): vscode.Disposable { + const contributions = context.extension.packageJSON.contributes as { + readonly commands?: readonly { + readonly command?: unknown; + readonly $generated?: unknown; + }[]; + } | undefined; + const registrations = (contributions?.commands ?? []) + .filter(command => command.$generated === true) + .map(command => { + const commandId = command.command; + if (typeof commandId !== 'string') { + throw new TypeError('Generated Markdown editor command is missing its command identifier.'); + } + return vscode.commands.registerCommand(commandId, () => provider.executeCommand(commandId)); + }); + return vscode.Disposable.from(...registrations); +} + function registerMarkdownLanguageFeatures( client: MdLanguageClient, commandManager: CommandManager, diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 1904b82736a..b8c572041eb 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -78,6 +78,8 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT readonly #linkOpener: MdLinkOpener; readonly #contributions: MarkdownContributionProvider; readonly #logger: ILogger; + readonly #webviewPanels = new Set(); + readonly #focusedWebviewPanels = new Set(); readonly #providerApis = new Map>(); readonly #resolvedCodeBlockEditors = new Map>(); readonly #resolvedCodeBlockEditorResources = new Set(); @@ -96,6 +98,9 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT this.#contributions = contributions; this.#logger = logger; this.#mediaRoot = vscode.Uri.joinPath(this.#extensionUri, 'markdown-editor-out'); + this._register(new vscode.Disposable(() => { + void vscode.commands.executeCommand('setContext', 'markdownEditorFocus', false); + })); } public async resolveCustomTextEditor( @@ -137,6 +142,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT return; } const webview = webviewPanel.webview; + this.#webviewPanels.add(webviewPanel); const codeBlockEditorProviders = await this.#loadCodeBlockEditorProviders(); if (token.isCancellationRequested) { return; @@ -206,6 +212,17 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT break; } + case 'editorFocusChanged': { + if (message.focused) { + this.#focusedWebviewPanels.add(webviewPanel); + } else { + this.#focusedWebviewPanels.delete(webviewPanel); + } + await this.#updateEditorFocusContext(); + break; + } + + case 'setReadonly': { // Remember the edit/read-only choice as the global default for the // next Markdown editor. @@ -304,11 +321,15 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const onDidDeleteFiles = vscode.workspace.onDidDeleteFiles(event => invalidateResourceCache(event.files)); const onDidRenameFiles = vscode.workspace.onDidRenameFiles(event => invalidateResourceCache( event.files.flatMap(file => [file.oldUri, file.newUri]))); + const onDidChangeViewState = webviewPanel.onDidChangeViewState(() => this.#updateEditorFocusContext()); webviewPanel.onDidDispose(() => { contributionUpdate++; resolveCancellation.cancel(); resolveCancellation.dispose(); + this.#webviewPanels.delete(webviewPanel); + this.#focusedWebviewPanels.delete(webviewPanel); + this.#updateEditorFocusContext(); onMessage.dispose(); onDocumentChange.dispose(); highlight.dispose(); @@ -320,9 +341,24 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT onDidCreateFiles.dispose(); onDidDeleteFiles.dispose(); onDidRenameFiles.dispose(); + onDidChangeViewState.dispose(); }); } + public async executeCommand(command: string): Promise { + const activePanel = Array.from(this.#webviewPanels).find(panel => panel.active); + if (!activePanel) { + this.#logger.trace('Markdown editor command', `Ignored ${command} because no Markdown editor is active`); + return; + } + await activePanel.webview.postMessage({ type: 'command', command }); + } + + async #updateEditorFocusContext(): Promise { + const focused = Array.from(this.#focusedWebviewPanels).some(panel => panel.active); + await vscode.commands.executeCommand('setContext', 'markdownEditorFocus', focused); + } + async #loadCodeBlockEditorProviders(): Promise { const result: CodeBlockEditorProviderDefinition[] = []; for (const provider of this.#contributions.contributions.codeBlockEditorProviders) { diff --git a/package.json b/package.json index d8dd35651d2..e9705e53ed6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test-extension": "vscode-test", "test-build-scripts": "cd build && npm run test", "test-agent-host-e2e": "node scripts/test-agent-host-e2e.ts", + "markdown-editor-package-json-check": "npm --prefix extensions/markdown-language-features run check-markdown-editor-package-json", "test-agent-host-e2e-coverage": "node scripts/agent-host-e2e-coverage.ts", "check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out", "preinstall": "node build/npm/preinstall.ts",