From 8bf82819fc403cac39ee59b5b836876652c38c67 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Tue, 9 Aug 2022 08:31:40 -0700 Subject: [PATCH] Add experimental support for update markdown links on file moves/renames (#157209) * Add experimental support for update markdown links on file moves/renames Fixes #148146 This adds a new experimental setting that automatically updates markdown Note that this needs a new version of the vscode-markdown-languageservice so the build is expected to break for now * Pick up new LS version --- .../markdown-language-features/package.json | 28 +++ .../package.nls.json | 5 + .../server/package.json | 2 +- .../server/src/protocol.ts | 1 + .../server/src/server.ts | 9 + .../server/yarn.lock | 8 +- .../src/extension.shared.ts | 2 + .../src/languageFeatures/fileReferences.ts | 7 +- .../languageFeatures/updatePathsOnRename.ts | 234 ++++++++++++++++++ .../src/protocol.ts | 1 + 10 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 extensions/markdown-language-features/src/languageFeatures/updatePathsOnRename.ts diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 6f3c76d708f..808cf0bc3d9 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -509,6 +509,34 @@ "tags": [ "experimental" ] + }, + "markdown.experimental.updateLinksOnFileMove.enabled": { + "type": "string", + "enum": [ + "prompt", + "always", + "never" + ], + "markdownEnumDescriptions": [ + "%configuration.markdown.experimental.updateLinksOnFileMove.enabled.prompt%", + "%configuration.markdown.experimental.updateLinksOnFileMove.enabled.always%", + "%configuration.markdown.experimental.updateLinksOnFileMove.enabled.never%" + ], + "default": "never", + "markdownDescription": "%configuration.markdown.experimental.updateLinksOnFileMove.enabled%", + "scope": "resource", + "tags": [ + "experimental" + ] + }, + "markdown.experimental.updateLinksOnFileMove.externalFileGlobs": { + "type": "string", + "default": "**/*.{jpg,jpe,jpeg,png,bmp,gif,ico,webp,avif}", + "description": "%configuration.markdown.experimental.updateLinksOnFileMove.fileGlobs%", + "scope": "resource", + "tags": [ + "experimental" + ] } } }, diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index 1c32e447e05..6db1bc6a600 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -37,5 +37,10 @@ "configuration.markdown.experimental.validate.fileLinks.enabled.description": "Validate links to other files in Markdown files, e.g. `[link](/path/to/file.md)`. This checks that the target files exists. Requires enabling `#markdown.experimental.validate.enabled#`.", "configuration.markdown.experimental.validate.fileLinks.markdownFragmentLinks.description": "Validate the fragment part of links to headers in other files in Markdown files, e.g. `[link](/path/to/file.md#header)`. Inherits the setting value from `#markdown.experimental.validate.fragmentLinks.enabled#` by default.", "configuration.markdown.experimental.validate.ignoreLinks.description": "Configure links that should not be validated. For example `/about` would not validate the link `[about](/about)`, while the glob `/assets/**/*.svg` would let you skip validation for any link to `.svg` files under the `assets` directory.", + "configuration.markdown.experimental.updateLinksOnFileMove.enabled": "Try to update links in Markdown files when a file is renamed/moved in the workspace. Use `#markdown.experimental.updateLinksOnFileMove.externalFileGlobs#` to configure which files trigger link updates.", + "configuration.markdown.experimental.updateLinksOnFileMove.enabled.prompt": "Prompt on each file move.", + "configuration.markdown.experimental.updateLinksOnFileMove.enabled.always": "Always update links automatically.", + "configuration.markdown.experimental.updateLinksOnFileMove.enabled.never": "Never try to update link and don't prompt.", + "configuration.markdown.experimental.updateLinksOnFileMove.fileGlobs": "A glob that specifies which files besides markdown should trigger a link update.", "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 ee4d642a4c6..974cad30efa 100644 --- a/extensions/markdown-language-features/server/package.json +++ b/extensions/markdown-language-features/server/package.json @@ -13,7 +13,7 @@ "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.5", "vscode-languageserver-types": "^3.17.1", - "vscode-markdown-languageservice": "^0.0.0-alpha.13", + "vscode-markdown-languageservice": "^0.0.0-alpha.14", "vscode-uri": "^3.0.3" }, "devDependencies": { diff --git a/extensions/markdown-language-features/server/src/protocol.ts b/extensions/markdown-language-features/server/src/protocol.ts index 4b045dce0d3..9aa467d2b1e 100644 --- a/extensions/markdown-language-features/server/src/protocol.ts +++ b/extensions/markdown-language-features/server/src/protocol.ts @@ -22,6 +22,7 @@ export const findMarkdownFilesInWorkspace = new RequestType<{}, string[], any>(' //#region To server export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace'); +export const getEditForFileRenames = new RequestType, lsp.WorkspaceEdit, any>('markdown/getEditForFileRenames'); export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange'); //#endregion diff --git a/extensions/markdown-language-features/server/src/server.ts b/extensions/markdown-language-features/server/src/server.ts index d16c2d0d34e..0bfeb9e166d 100644 --- a/extensions/markdown-language-features/server/src/server.ts +++ b/extensions/markdown-language-features/server/src/server.ts @@ -203,6 +203,15 @@ export async function startServer(connection: Connection) { return undefined; })); + connection.onRequest(protocol.getEditForFileRenames, (async (params, token: CancellationToken) => { + try { + return await provider!.getRenameFilesInWorkspaceEdit(params.map(x => ({ oldUri: URI.parse(x.oldUri), newUri: URI.parse(x.newUri) })), token); + } catch (e) { + console.error(e.stack); + } + return undefined; + })); + documents.listen(connection); notebooks.listen(connection); connection.listen(); diff --git a/extensions/markdown-language-features/server/yarn.lock b/extensions/markdown-language-features/server/yarn.lock index 6d8871a746a..326859f1f5d 100644 --- a/extensions/markdown-language-features/server/yarn.lock +++ b/extensions/markdown-language-features/server/yarn.lock @@ -42,10 +42,10 @@ vscode-languageserver@^8.0.2: dependencies: vscode-languageserver-protocol "3.17.2" -vscode-markdown-languageservice@^0.0.0-alpha.13: - version "0.0.0-alpha.13" - resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.0.0-alpha.13.tgz#28cd8dd8eca451aaa3db1c92ec97ace53623dd5d" - integrity sha512-jgRVBQmdO0aC5Svap1RcAd3x2XOSNWla01GF/rzaVx9M5pEcel4SPz+2H9PYXul6jRKe1oKJF9OOciaiE7pSXQ== +vscode-markdown-languageservice@^0.0.0-alpha.14: + version "0.0.0-alpha.14" + resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.0.0-alpha.14.tgz#befe2fd1571213db0abbd9c93a4b9adf22f68d5c" + integrity sha512-6rxEZKnYTJfZBOIWfPeUm5cjss7hgnJ7lQ8ZA4b918SjcOlDT0NOCQZ/88vMuxWdKKQCywcD9YoXNMRYsT+N5w== dependencies: picomatch "^2.3.1" vscode-languageserver-textdocument "^1.0.5" diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index 4a8e043b520..f679a3be9dd 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -11,6 +11,7 @@ import { registerPasteSupport } from './languageFeatures/copyPaste'; import { registerDiagnosticSupport } from './languageFeatures/diagnostics'; import { registerDropIntoEditorSupport } from './languageFeatures/dropIntoEditor'; import { registerFindFileReferenceSupport } from './languageFeatures/fileReferences'; +import { registerUpdatePathsOnRename } from './languageFeatures/updatePathsOnRename'; import { ILogger } from './logging'; import { MarkdownItEngine, MdParsingProvider } from './markdownEngine'; import { MarkdownContributionProvider } from './markdownExtensions'; @@ -62,6 +63,7 @@ function registerMarkdownLanguageFeatures( registerDropIntoEditorSupport(selector), registerFindFileReferenceSupport(commandManager, client), registerPasteSupport(selector), + registerUpdatePathsOnRename(client), ); } diff --git a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts index 7c6338ede98..6d8b5eab079 100644 --- a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts +++ b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { BaseLanguageClient } from 'vscode-languageclient'; +import type * as lsp from 'vscode-languageserver-types'; import * as nls from 'vscode-nls'; import { Command, CommandManager } from '../commandManager'; import { getReferencesToFileInWorkspace } from '../protocol'; @@ -35,7 +36,7 @@ export class FindFileReferencesCommand implements Command { title: localize('progress.title', "Finding file references") }, async (_progress, token) => { const locations = (await this.client.sendRequest(getReferencesToFileInWorkspace, { uri: resource!.toString() }, token)).map(loc => { - return new vscode.Location(vscode.Uri.parse(loc.uri), new vscode.Range(loc.range.start.line, loc.range.start.character, loc.range.end.line, loc.range.end.character)); + return new vscode.Location(vscode.Uri.parse(loc.uri), convertRange(loc.range)); }); const config = vscode.workspace.getConfiguration('references'); @@ -51,6 +52,10 @@ export class FindFileReferencesCommand implements Command { } } +export function convertRange(range: lsp.Range): vscode.Range { + return new vscode.Range(range.start.line, range.start.character, range.end.line, range.end.character); +} + export function registerFindFileReferenceSupport( commandManager: CommandManager, client: BaseLanguageClient, diff --git a/extensions/markdown-language-features/src/languageFeatures/updatePathsOnRename.ts b/extensions/markdown-language-features/src/languageFeatures/updatePathsOnRename.ts new file mode 100644 index 00000000000..911acf6bdf9 --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/updatePathsOnRename.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as picomatch from 'picomatch'; +import * as vscode from 'vscode'; +import { BaseLanguageClient } from 'vscode-languageclient'; +import * as nls from 'vscode-nls'; +import { getEditForFileRenames } from '../protocol'; +import { Delayer } from '../util/async'; +import { noopToken } from '../util/cancellation'; +import { Disposable } from '../util/dispose'; +import { looksLikeMarkdownPath } from '../util/file'; +import { convertRange } from './fileReferences'; + +const localize = nls.loadMessageBundle(); + +const settingNames = Object.freeze({ + enabled: 'experimental.updateLinksOnFileMove.enabled', + externalFileGlobs: 'experimental.updateLinksOnFileMove.externalFileGlobs' +}); + +const enum UpdateLinksOnFileMoveSetting { + Prompt = 'prompt', + Always = 'always', + Never = 'never', +} + +interface RenameAction { + readonly oldUri: vscode.Uri; + readonly newUri: vscode.Uri; +} + +class UpdateImportsOnFileRenameHandler extends Disposable { + + private readonly _delayer = new Delayer(50); + private readonly _pendingRenames = new Set(); + + public constructor( + private readonly client: BaseLanguageClient, + ) { + super(); + + this._register(vscode.workspace.onDidRenameFiles(async (e) => { + const [{ newUri, oldUri }] = e.files; // TODO: only handles first file + + const config = this.getConfiguration(newUri); + + const setting = config.get(settingNames.enabled); + if (setting === UpdateLinksOnFileMoveSetting.Never) { + return; + } + + if (!this.shouldParticipateInLinkUpdate(config, newUri)) { + return; + } + + this._pendingRenames.add({ oldUri, newUri }); + + this._delayer.trigger(() => { + vscode.window.withProgress({ + location: vscode.ProgressLocation.Window, + title: localize('renameProgress.title', "Checking for Markdown links to update") + }, () => this.flushRenames()); + }); + })); + } + + private async flushRenames(): Promise { + const renames = Array.from(this._pendingRenames); + this._pendingRenames.clear(); + + const edit = new vscode.WorkspaceEdit(); + const resourcesBeingRenamed: vscode.Uri[] = []; + + for (const { oldUri, newUri } of renames) { + if (await this.withEditsForFileRename(edit, oldUri, newUri, noopToken)) { + resourcesBeingRenamed.push(newUri); + } + } + + if (edit.size) { + if (await this.confirmActionWithUser(resourcesBeingRenamed)) { + await vscode.workspace.applyEdit(edit); + } + } + } + + private async confirmActionWithUser(newResources: readonly vscode.Uri[]): Promise { + if (!newResources.length) { + return false; + } + + const config = this.getConfiguration(newResources[0]); + const setting = config.get(settingNames.enabled); + switch (setting) { + case UpdateLinksOnFileMoveSetting.Prompt: + return this.promptUser(newResources); + case UpdateLinksOnFileMoveSetting.Always: + return true; + case UpdateLinksOnFileMoveSetting.Never: + default: + return false; + } + } + + private getConfiguration(resource: vscode.Uri) { + return vscode.workspace.getConfiguration('markdown', resource); + } + + private shouldParticipateInLinkUpdate(config: vscode.WorkspaceConfiguration, newUri: vscode.Uri) { + if (looksLikeMarkdownPath(newUri)) { + return true; + } + + const externalGlob = config.get(settingNames.externalFileGlobs); + return !!externalGlob && picomatch.isMatch(newUri.fsPath, externalGlob); + } + + private async promptUser(newResources: readonly vscode.Uri[]): Promise { + if (!newResources.length) { + return false; + } + + const enum Choice { + None = 0, + Accept = 1, + Reject = 2, + Always = 3, + Never = 4, + } + + interface Item extends vscode.MessageItem { + readonly choice: Choice; + } + + const response = await vscode.window.showInformationMessage( + newResources.length === 1 + ? localize('prompt', "Update Markdown links for '{0}'?", path.basename(newResources[0].fsPath)) + : this.getConfirmMessage(localize('promptMoreThanOne', "Update Markdown link for the following {0} files?", newResources.length), newResources), { + modal: true, + }, { + title: localize('reject.title', "No"), + choice: Choice.Reject, + isCloseAffordance: true, + }, { + title: localize('accept.title', "Yes"), + choice: Choice.Accept, + }, { + title: localize('always.title', "Always automatically update Markdown Links"), + choice: Choice.Always, + }, { + title: localize('never.title', "Never automatically update Markdown Links"), + choice: Choice.Never, + }); + + if (!response) { + return false; + } + + switch (response.choice) { + case Choice.Accept: { + return true; + } + case Choice.Reject: { + return false; + } + case Choice.Always: { + const config = this.getConfiguration(newResources[0]); + config.update( + settingNames.enabled, + UpdateLinksOnFileMoveSetting.Always, + vscode.ConfigurationTarget.Global); + return true; + } + case Choice.Never: { + const config = this.getConfiguration(newResources[0]); + config.update( + settingNames.enabled, + UpdateLinksOnFileMoveSetting.Never, + vscode.ConfigurationTarget.Global); + return false; + } + } + + return false; + } + + private async withEditsForFileRename( + workspaceEdit: vscode.WorkspaceEdit, + oldUri: vscode.Uri, + newUri: vscode.Uri, + token: vscode.CancellationToken, + ): Promise { + const edit = await this.client.sendRequest(getEditForFileRenames, [{ oldUri: oldUri.toString(), newUri: newUri.toString() }], token); + if (!edit.changes) { + return false; + } + + for (const [path, edits] of Object.entries(edit.changes)) { + const uri = vscode.Uri.parse(path); + for (const edit of edits) { + workspaceEdit.replace(uri, convertRange(edit.range), edit.newText); + } + } + + return true; + } + + private getConfirmMessage(start: string, resourcesToConfirm: readonly vscode.Uri[]): string { + const MAX_CONFIRM_FILES = 10; + + const paths = [start]; + paths.push(''); + paths.push(...resourcesToConfirm.slice(0, MAX_CONFIRM_FILES).map(r => path.basename(r.fsPath))); + + if (resourcesToConfirm.length > MAX_CONFIRM_FILES) { + if (resourcesToConfirm.length - MAX_CONFIRM_FILES === 1) { + paths.push(localize('moreFile', "...1 additional file not shown")); + } else { + paths.push(localize('moreFiles', "...{0} additional files not shown", resourcesToConfirm.length - MAX_CONFIRM_FILES)); + } + } + + paths.push(''); + return paths.join('\n'); + } +} + +export function registerUpdatePathsOnRename(client: BaseLanguageClient) { + return new UpdateImportsOnFileRenameHandler(client); +} diff --git a/extensions/markdown-language-features/src/protocol.ts b/extensions/markdown-language-features/src/protocol.ts index 53bb27b9822..51fcc8ab2d8 100644 --- a/extensions/markdown-language-features/src/protocol.ts +++ b/extensions/markdown-language-features/src/protocol.ts @@ -23,6 +23,7 @@ export const findMarkdownFilesInWorkspace = new RequestType<{}, string[], any>(' //#region To server export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace'); +export const getEditForFileRenames = new RequestType, lsp.WorkspaceEdit, any>('markdown/getEditForFileRenames'); export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange'); //#endregion