diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index a80b0294786d3..ddfdaebda7b03 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -708,6 +708,15 @@ "%configuration.markdown.preferredMdPathExtensionStyle.includeExtension%", "%configuration.markdown.preferredMdPathExtensionStyle.removeExtension%" ] + }, + "markdown.experimental.updateLinksOnPaste": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.markdown.experimental.updateLinksOnPaste%", + "scope": "resource", + "tags": [ + "experimental" + ] } } }, diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index a4b25260ba815..3549ec58c517b 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -91,5 +91,6 @@ "configuration.markdown.preferredMdPathExtensionStyle.removeExtension": "Prefer removing the file extension. For example, path completions to a file named `file.md` will insert `file` without the `.md`.", "configuration.markdown.editor.filePaste.videoSnippet": "Snippet used when adding videos to Markdown. This snippet can use the following variables:\n- `${src}` — The resolved path of the video file.\n- `${title}` — The title used for the video. A snippet placeholder will automatically be created for this variable.", "configuration.markdown.editor.filePaste.audioSnippet": "Snippet used when adding audio to Markdown. This snippet can use the following variables:\n- `${src}` — The resolved path of the audio file.\n- `${title}` — The title used for the audio. A snippet placeholder will automatically be created for this variable.", + "configuration.markdown.experimental.updateLinksOnPaste": "Enable/disable automatic updating of links in text that is copied and pasted from one Markdown editor to another.", "workspaceTrust": "Required for loading styles configured in the workspace." } diff --git a/extensions/markdown-language-features/server/package.json b/extensions/markdown-language-features/server/package.json index 175620ee8de4c..e6c41d5e63dc8 100644 --- a/extensions/markdown-language-features/server/package.json +++ b/extensions/markdown-language-features/server/package.json @@ -1,7 +1,7 @@ { "name": "vscode-markdown-languageserver", "description": "Markdown language server", - "version": "0.4.0", + "version": "0.5.0-alpha.3", "author": "Microsoft Corporation", "license": "MIT", "engines": { @@ -18,7 +18,7 @@ "vscode-languageserver": "^8.1.0", "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3", - "vscode-markdown-languageservice": "^0.5.0-alpha.1", + "vscode-markdown-languageservice": "^0.5.0-alpha.3", "vscode-uri": "^3.0.7" }, "devDependencies": { diff --git a/extensions/markdown-language-features/server/src/protocol.ts b/extensions/markdown-language-features/server/src/protocol.ts index e1dc9aee78532..3a195755e056b 100644 --- a/extensions/markdown-language-features/server/src/protocol.ts +++ b/extensions/markdown-language-features/server/src/protocol.ts @@ -24,6 +24,9 @@ export const findMarkdownFilesInWorkspace = new RequestType<{}, string[], any>(' export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace'); export const getEditForFileRenames = new RequestType('markdown/getEditForFileRenames'); +export const prepareUpdatePastedLinks = new RequestType<{ uri: string; ranges: lsp.Range[] }, string, any>('markdown/prepareUpdatePastedLinks'); +export const getUpdatePastedLinksEdit = new RequestType<{ pasteIntoDoc: string; metadata: string; edits: lsp.TextEdit[] }, lsp.TextEdit[] | undefined, any>('markdown/getUpdatePastedLinksEdit'); + export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange'); export const resolveLinkTarget = new RequestType<{ linkText: string; uri: string }, md.ResolvedDocumentLinkTarget, any>('markdown/resolveLinkTarget'); diff --git a/extensions/markdown-language-features/server/src/server.ts b/extensions/markdown-language-features/server/src/server.ts index 50c343784714e..bcfbb1be899d1 100644 --- a/extensions/markdown-language-features/server/src/server.ts +++ b/extensions/markdown-language-features/server/src/server.ts @@ -262,6 +262,26 @@ export async function startServer(connection: Connection, serverConfig: { }; })); + connection.onRequest(protocol.prepareUpdatePastedLinks, (async (params, token: CancellationToken) => { + const document = documents.get(params.uri); + if (!document) { + return undefined; + } + + return mdLs!.prepareUpdatePastedLinks(document, params.ranges, token); + })); + + connection.onRequest(protocol.getUpdatePastedLinksEdit, (async (params, token: CancellationToken) => { + const document = documents.get(params.pasteIntoDoc); + if (!document) { + return undefined; + } + + // TODO: Figure out why range types are lying + const edits = params.edits.map((edit: any) => lsp.TextEdit.replace(lsp.Range.create(edit.range[0].line, edit.range[0].character, edit.range[1].line, edit.range[1].character), edit.newText)); + return mdLs!.getUpdatePastedLinksEdit(document, edits, params.metadata, token); + })); + connection.onRequest(protocol.resolveLinkTarget, (async (params, token: CancellationToken) => { return mdLs!.resolveLinkTarget(params.linkText, URI.parse(params.uri), token); })); diff --git a/extensions/markdown-language-features/server/src/workspace.ts b/extensions/markdown-language-features/server/src/workspace.ts index b1bf87c302083..13e5c6b447287 100644 --- a/extensions/markdown-language-features/server/src/workspace.ts +++ b/extensions/markdown-language-features/server/src/workspace.ts @@ -63,6 +63,18 @@ class VsCodeDocument implements md.ITextDocument { throw new Error('Document has been closed'); } + offsetAt(position: Position): number { + if (this.inMemoryDoc) { + return this.inMemoryDoc.offsetAt(position); + } + + if (this.onDiskDoc) { + return this.onDiskDoc.offsetAt(position); + } + + throw new Error('Document has been closed'); + } + hasInMemoryDoc(): boolean { return !!this.inMemoryDoc; } diff --git a/extensions/markdown-language-features/server/yarn.lock b/extensions/markdown-language-features/server/yarn.lock index d630fdb5e8835..2fd60de4aeb5f 100644 --- a/extensions/markdown-language-features/server/yarn.lock +++ b/extensions/markdown-language-features/server/yarn.lock @@ -124,6 +124,11 @@ vscode-languageserver-protocol@^3.17.1: vscode-jsonrpc "8.2.0" vscode-languageserver-types "3.17.5" +vscode-languageserver-textdocument@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz#0822a000e7d4dc083312580d7575fe9e3ba2e2bf" + integrity sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA== + vscode-languageserver-textdocument@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.8.tgz#9eae94509cbd945ea44bca8dcfe4bb0c15bb3ac0" @@ -146,15 +151,16 @@ vscode-languageserver@^8.1.0: dependencies: vscode-languageserver-protocol "3.17.3" -vscode-markdown-languageservice@^0.5.0-alpha.1: - version "0.5.0-alpha.1" - resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0-alpha.1.tgz#0b03f1f8e853e20352587a8de6a2f10f8d14c81c" - integrity sha512-7uVtRSr4+/xlsml9QtBkBAHPmtBv71CKEj6zhPTERYZEOHCwFsue1EHkESWBOGuqQ1NSLXPnHWENcDh5VD+bNw== +vscode-markdown-languageservice@^0.5.0-alpha.3: + version "0.5.0-alpha.3" + resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0-alpha.3.tgz#417e853c73ce2e1a2460e52d8868576365d6cb05" + integrity sha512-wD9LO4CWtrp7dqbQBoQ6HbQpDpN2lUTC6SvDDjhZVDqRU0XqJP5YyO4FsvY+MWwz75b3QwapYYv4635EY4xhrA== dependencies: "@vscode/l10n" "^0.0.10" node-html-parser "^6.1.5" picomatch "^2.3.1" vscode-languageserver-protocol "^3.17.1" + vscode-languageserver-textdocument "^1.0.11" vscode-uri "^3.0.7" vscode-uri@^3.0.7: diff --git a/extensions/markdown-language-features/src/client/client.ts b/extensions/markdown-language-features/src/client/client.ts index f0e925f615746..cf7dee25246cf 100644 --- a/extensions/markdown-language-features/src/client/client.ts +++ b/extensions/markdown-language-features/src/client/client.ts @@ -4,14 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { BaseLanguageClient, LanguageClientOptions, NotebookDocumentSyncRegistrationType } from 'vscode-languageclient'; +import { BaseLanguageClient, LanguageClientOptions, NotebookDocumentSyncRegistrationType, Range, TextEdit } from 'vscode-languageclient'; import { IMdParser } from '../markdownEngine'; -import * as proto from './protocol'; +import { IDisposable } from '../util/dispose'; import { looksLikeMarkdownPath, markdownFileExtensions } from '../util/file'; -import { VsCodeMdWorkspace } from './workspace'; import { FileWatcherManager } from './fileWatchingManager'; -import { IDisposable } from '../util/dispose'; - +import * as proto from './protocol'; +import { VsCodeMdWorkspace } from './workspace'; export type LanguageClientConstructor = (name: string, description: string, clientOptions: LanguageClientOptions) => BaseLanguageClient; @@ -38,6 +37,21 @@ export class MdLanguageClient implements IDisposable { getReferencesToFileInWorkspace(resource: vscode.Uri, token: vscode.CancellationToken) { return this._client.sendRequest(proto.getReferencesToFileInWorkspace, { uri: resource.toString() }, token); } + + prepareUpdatePastedLinks(doc: vscode.Uri, ranges: readonly vscode.Range[], token: vscode.CancellationToken) { + return this._client.sendRequest(proto.prepareUpdatePastedLinks, { + uri: doc.toString(), + ranges: ranges.map(range => Range.create(range.start.line, range.start.character, range.end.line, range.end.character)), + }, token); + } + + getUpdatePastedLinksEdit(pastingIntoDoc: vscode.Uri, edits: readonly vscode.TextEdit[], metadata: string, token: vscode.CancellationToken) { + return this._client.sendRequest(proto.getUpdatePastedLinksEdit, { + metadata, + pasteIntoDoc: pastingIntoDoc.toString(), + edits: edits.map(edit => TextEdit.replace(edit.range, edit.newText)), + }, token); + } } export async function startClient(factory: LanguageClientConstructor, parser: IMdParser): Promise { diff --git a/extensions/markdown-language-features/src/client/protocol.ts b/extensions/markdown-language-features/src/client/protocol.ts index f906460fce9ed..2f6c48b371d7c 100644 --- a/extensions/markdown-language-features/src/client/protocol.ts +++ b/extensions/markdown-language-features/src/client/protocol.ts @@ -32,6 +32,9 @@ export const findMarkdownFilesInWorkspace = new RequestType<{}, string[], any>(' export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace'); export const getEditForFileRenames = new RequestType, { participatingRenames: readonly FileRename[]; edit: lsp.WorkspaceEdit }, any>('markdown/getEditForFileRenames'); +export const prepareUpdatePastedLinks = new RequestType<{ uri: string; ranges: lsp.Range[] }, string, any>('markdown/prepareUpdatePastedLinks'); +export const getUpdatePastedLinksEdit = new RequestType<{ pasteIntoDoc: string; metadata: string; edits: lsp.TextEdit[] }, lsp.TextEdit[] | undefined, any>('markdown/getUpdatePastedLinksEdit'); + export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange'); export const resolveLinkTarget = new RequestType<{ linkText: string; uri: string }, ResolvedDocumentLinkTarget, any>('markdown/resolveLinkTarget'); diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index e8758bad8327e..e062666c748d4 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -20,6 +20,7 @@ import { MarkdownPreviewManager } from './preview/previewManager'; import { ExtensionContentSecurityPolicyArbiter } from './preview/security'; import { loadDefaultTelemetryReporter } from './telemetryReporter'; import { MdLinkOpener } from './util/openDocumentLink'; +import { registerUpdatePastedLinks } from './languageFeatures/updateLinksOnPaste'; export function activateShared( context: vscode.ExtensionContext, @@ -61,5 +62,6 @@ function registerMarkdownLanguageFeatures( registerResourceDropOrPasteSupport(selector, parser), registerPasteUrlSupport(selector, parser), registerUpdateLinksOnRename(client), + registerUpdatePastedLinks(selector, client), ); } diff --git a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts index 2f2af15df0871..bda8b721e8bca 100644 --- a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts +++ b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts @@ -28,7 +28,7 @@ export class FindFileReferencesCommand implements Command { location: vscode.ProgressLocation.Window, title: vscode.l10n.t("Finding file references") }, async (_progress, token) => { - const locations = (await this._client.getReferencesToFileInWorkspace(resource!, token)).map(loc => { + const locations = (await this._client.getReferencesToFileInWorkspace(resource, token)).map(loc => { return new vscode.Location(vscode.Uri.parse(loc.uri), convertRange(loc.range)); }); diff --git a/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts b/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts new file mode 100644 index 0000000000000..dd244cac76b43 --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { MdLanguageClient } from '../client/client'; +import { Mime } from '../util/mimes'; + +class UpdatePastedLinksEditProvider implements vscode.DocumentPasteEditProvider { + + public static readonly kind = vscode.DocumentPasteEditKind.Empty.append('text', 'markdown', 'updateLinks'); + + public static readonly metadataMime = 'vnd.vscode.markdown.updateLinksMetadata'; + + constructor( + private readonly _client: MdLanguageClient, + ) { } + + async prepareDocumentPaste(document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { + if (!this._isEnabled(document)) { + return; + } + + const metadata = await this._client.prepareUpdatePastedLinks(document.uri, ranges, token); + if (token.isCancellationRequested) { + return; + } + dataTransfer.set(UpdatePastedLinksEditProvider.metadataMime, new vscode.DataTransferItem(metadata)); + } + + async provideDocumentPasteEdits( + document: vscode.TextDocument, + ranges: readonly vscode.Range[], + dataTransfer: vscode.DataTransfer, + _context: vscode.DocumentPasteEditContext, + token: vscode.CancellationToken, + ): Promise { + if (!this._isEnabled(document)) { + return; + } + + const metadata = dataTransfer.get(UpdatePastedLinksEditProvider.metadataMime)?.value; + if (!metadata) { + return; + } + + const textItem = dataTransfer.get(Mime.textPlain); + const text = await textItem?.asString(); + if (!text || token.isCancellationRequested) { + return; + } + + // TODO: Handle cases such as: + // - copy empty line + // - Copy with multiple cursors and paste into multiple locations + // - ... + const edits = await this._client.getUpdatePastedLinksEdit(document.uri, ranges.map(x => new vscode.TextEdit(x, text)), metadata, token); + if (!edits || !edits.length || token.isCancellationRequested) { + return; + } + + const pasteEdit = new vscode.DocumentPasteEdit('', vscode.l10n.t("Paste and update pasted links"), UpdatePastedLinksEditProvider.kind); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(document.uri, edits.map(x => new vscode.TextEdit(new vscode.Range(x.range.start.line, x.range.start.character, x.range.end.line, x.range.end.character,), x.newText))); + pasteEdit.additionalEdit = workspaceEdit; + return [pasteEdit]; + } + + private _isEnabled(document: vscode.TextDocument): boolean { + return vscode.workspace.getConfiguration('markdown', document.uri).get('experimental.updateLinksOnPaste', false); + } +} + +export function registerUpdatePastedLinks(selector: vscode.DocumentSelector, client: MdLanguageClient) { + return vscode.languages.registerDocumentPasteEditProvider(selector, new UpdatePastedLinksEditProvider(client), { + copyMimeTypes: [UpdatePastedLinksEditProvider.metadataMime], + providedPasteEditKinds: [UpdatePastedLinksEditProvider.kind], + pasteMimeTypes: [UpdatePastedLinksEditProvider.metadataMime], + }); +}