Skip to content

Commit 8bf8281

Browse files
authored
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
1 parent 81e6a02 commit 8bf8281

File tree

10 files changed

+291
-6
lines changed

10 files changed

+291
-6
lines changed

extensions/markdown-language-features/package.json

+28
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,34 @@
509509
"tags": [
510510
"experimental"
511511
]
512+
},
513+
"markdown.experimental.updateLinksOnFileMove.enabled": {
514+
"type": "string",
515+
"enum": [
516+
"prompt",
517+
"always",
518+
"never"
519+
],
520+
"markdownEnumDescriptions": [
521+
"%configuration.markdown.experimental.updateLinksOnFileMove.enabled.prompt%",
522+
"%configuration.markdown.experimental.updateLinksOnFileMove.enabled.always%",
523+
"%configuration.markdown.experimental.updateLinksOnFileMove.enabled.never%"
524+
],
525+
"default": "never",
526+
"markdownDescription": "%configuration.markdown.experimental.updateLinksOnFileMove.enabled%",
527+
"scope": "resource",
528+
"tags": [
529+
"experimental"
530+
]
531+
},
532+
"markdown.experimental.updateLinksOnFileMove.externalFileGlobs": {
533+
"type": "string",
534+
"default": "**/*.{jpg,jpe,jpeg,png,bmp,gif,ico,webp,avif}",
535+
"description": "%configuration.markdown.experimental.updateLinksOnFileMove.fileGlobs%",
536+
"scope": "resource",
537+
"tags": [
538+
"experimental"
539+
]
512540
}
513541
}
514542
},

extensions/markdown-language-features/package.nls.json

+5
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,10 @@
3737
"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#`.",
3838
"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.",
3939
"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.",
40+
"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.",
41+
"configuration.markdown.experimental.updateLinksOnFileMove.enabled.prompt": "Prompt on each file move.",
42+
"configuration.markdown.experimental.updateLinksOnFileMove.enabled.always": "Always update links automatically.",
43+
"configuration.markdown.experimental.updateLinksOnFileMove.enabled.never": "Never try to update link and don't prompt.",
44+
"configuration.markdown.experimental.updateLinksOnFileMove.fileGlobs": "A glob that specifies which files besides markdown should trigger a link update.",
4045
"workspaceTrust": "Required for loading styles configured in the workspace."
4146
}

extensions/markdown-language-features/server/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"vscode-languageserver": "^8.0.2",
1414
"vscode-languageserver-textdocument": "^1.0.5",
1515
"vscode-languageserver-types": "^3.17.1",
16-
"vscode-markdown-languageservice": "^0.0.0-alpha.13",
16+
"vscode-markdown-languageservice": "^0.0.0-alpha.14",
1717
"vscode-uri": "^3.0.3"
1818
},
1919
"devDependencies": {

extensions/markdown-language-features/server/src/protocol.ts

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const findMarkdownFilesInWorkspace = new RequestType<{}, string[], any>('
2222

2323
//#region To server
2424
export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace');
25+
export const getEditForFileRenames = new RequestType<Array<{ oldUri: string; newUri: string }>, lsp.WorkspaceEdit, any>('markdown/getEditForFileRenames');
2526

2627
export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange');
2728
//#endregion

extensions/markdown-language-features/server/src/server.ts

+9
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ export async function startServer(connection: Connection) {
203203
return undefined;
204204
}));
205205

206+
connection.onRequest(protocol.getEditForFileRenames, (async (params, token: CancellationToken) => {
207+
try {
208+
return await provider!.getRenameFilesInWorkspaceEdit(params.map(x => ({ oldUri: URI.parse(x.oldUri), newUri: URI.parse(x.newUri) })), token);
209+
} catch (e) {
210+
console.error(e.stack);
211+
}
212+
return undefined;
213+
}));
214+
206215
documents.listen(connection);
207216
notebooks.listen(connection);
208217
connection.listen();

extensions/markdown-language-features/server/yarn.lock

+4-4
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ vscode-languageserver@^8.0.2:
4242
dependencies:
4343
vscode-languageserver-protocol "3.17.2"
4444

45-
vscode-markdown-languageservice@^0.0.0-alpha.13:
46-
version "0.0.0-alpha.13"
47-
resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.0.0-alpha.13.tgz#28cd8dd8eca451aaa3db1c92ec97ace53623dd5d"
48-
integrity sha512-jgRVBQmdO0aC5Svap1RcAd3x2XOSNWla01GF/rzaVx9M5pEcel4SPz+2H9PYXul6jRKe1oKJF9OOciaiE7pSXQ==
45+
vscode-markdown-languageservice@^0.0.0-alpha.14:
46+
version "0.0.0-alpha.14"
47+
resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.0.0-alpha.14.tgz#befe2fd1571213db0abbd9c93a4b9adf22f68d5c"
48+
integrity sha512-6rxEZKnYTJfZBOIWfPeUm5cjss7hgnJ7lQ8ZA4b918SjcOlDT0NOCQZ/88vMuxWdKKQCywcD9YoXNMRYsT+N5w==
4949
dependencies:
5050
picomatch "^2.3.1"
5151
vscode-languageserver-textdocument "^1.0.5"

extensions/markdown-language-features/src/extension.shared.ts

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { registerPasteSupport } from './languageFeatures/copyPaste';
1111
import { registerDiagnosticSupport } from './languageFeatures/diagnostics';
1212
import { registerDropIntoEditorSupport } from './languageFeatures/dropIntoEditor';
1313
import { registerFindFileReferenceSupport } from './languageFeatures/fileReferences';
14+
import { registerUpdatePathsOnRename } from './languageFeatures/updatePathsOnRename';
1415
import { ILogger } from './logging';
1516
import { MarkdownItEngine, MdParsingProvider } from './markdownEngine';
1617
import { MarkdownContributionProvider } from './markdownExtensions';
@@ -62,6 +63,7 @@ function registerMarkdownLanguageFeatures(
6263
registerDropIntoEditorSupport(selector),
6364
registerFindFileReferenceSupport(commandManager, client),
6465
registerPasteSupport(selector),
66+
registerUpdatePathsOnRename(client),
6567
);
6668
}
6769

extensions/markdown-language-features/src/languageFeatures/fileReferences.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import * as vscode from 'vscode';
77
import { BaseLanguageClient } from 'vscode-languageclient';
8+
import type * as lsp from 'vscode-languageserver-types';
89
import * as nls from 'vscode-nls';
910
import { Command, CommandManager } from '../commandManager';
1011
import { getReferencesToFileInWorkspace } from '../protocol';
@@ -35,7 +36,7 @@ export class FindFileReferencesCommand implements Command {
3536
title: localize('progress.title', "Finding file references")
3637
}, async (_progress, token) => {
3738
const locations = (await this.client.sendRequest(getReferencesToFileInWorkspace, { uri: resource!.toString() }, token)).map(loc => {
38-
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));
39+
return new vscode.Location(vscode.Uri.parse(loc.uri), convertRange(loc.range));
3940
});
4041

4142
const config = vscode.workspace.getConfiguration('references');
@@ -51,6 +52,10 @@ export class FindFileReferencesCommand implements Command {
5152
}
5253
}
5354

55+
export function convertRange(range: lsp.Range): vscode.Range {
56+
return new vscode.Range(range.start.line, range.start.character, range.end.line, range.end.character);
57+
}
58+
5459
export function registerFindFileReferenceSupport(
5560
commandManager: CommandManager,
5661
client: BaseLanguageClient,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as path from 'path';
7+
import * as picomatch from 'picomatch';
8+
import * as vscode from 'vscode';
9+
import { BaseLanguageClient } from 'vscode-languageclient';
10+
import * as nls from 'vscode-nls';
11+
import { getEditForFileRenames } from '../protocol';
12+
import { Delayer } from '../util/async';
13+
import { noopToken } from '../util/cancellation';
14+
import { Disposable } from '../util/dispose';
15+
import { looksLikeMarkdownPath } from '../util/file';
16+
import { convertRange } from './fileReferences';
17+
18+
const localize = nls.loadMessageBundle();
19+
20+
const settingNames = Object.freeze({
21+
enabled: 'experimental.updateLinksOnFileMove.enabled',
22+
externalFileGlobs: 'experimental.updateLinksOnFileMove.externalFileGlobs'
23+
});
24+
25+
const enum UpdateLinksOnFileMoveSetting {
26+
Prompt = 'prompt',
27+
Always = 'always',
28+
Never = 'never',
29+
}
30+
31+
interface RenameAction {
32+
readonly oldUri: vscode.Uri;
33+
readonly newUri: vscode.Uri;
34+
}
35+
36+
class UpdateImportsOnFileRenameHandler extends Disposable {
37+
38+
private readonly _delayer = new Delayer(50);
39+
private readonly _pendingRenames = new Set<RenameAction>();
40+
41+
public constructor(
42+
private readonly client: BaseLanguageClient,
43+
) {
44+
super();
45+
46+
this._register(vscode.workspace.onDidRenameFiles(async (e) => {
47+
const [{ newUri, oldUri }] = e.files; // TODO: only handles first file
48+
49+
const config = this.getConfiguration(newUri);
50+
51+
const setting = config.get<UpdateLinksOnFileMoveSetting>(settingNames.enabled);
52+
if (setting === UpdateLinksOnFileMoveSetting.Never) {
53+
return;
54+
}
55+
56+
if (!this.shouldParticipateInLinkUpdate(config, newUri)) {
57+
return;
58+
}
59+
60+
this._pendingRenames.add({ oldUri, newUri });
61+
62+
this._delayer.trigger(() => {
63+
vscode.window.withProgress({
64+
location: vscode.ProgressLocation.Window,
65+
title: localize('renameProgress.title', "Checking for Markdown links to update")
66+
}, () => this.flushRenames());
67+
});
68+
}));
69+
}
70+
71+
private async flushRenames(): Promise<void> {
72+
const renames = Array.from(this._pendingRenames);
73+
this._pendingRenames.clear();
74+
75+
const edit = new vscode.WorkspaceEdit();
76+
const resourcesBeingRenamed: vscode.Uri[] = [];
77+
78+
for (const { oldUri, newUri } of renames) {
79+
if (await this.withEditsForFileRename(edit, oldUri, newUri, noopToken)) {
80+
resourcesBeingRenamed.push(newUri);
81+
}
82+
}
83+
84+
if (edit.size) {
85+
if (await this.confirmActionWithUser(resourcesBeingRenamed)) {
86+
await vscode.workspace.applyEdit(edit);
87+
}
88+
}
89+
}
90+
91+
private async confirmActionWithUser(newResources: readonly vscode.Uri[]): Promise<boolean> {
92+
if (!newResources.length) {
93+
return false;
94+
}
95+
96+
const config = this.getConfiguration(newResources[0]);
97+
const setting = config.get<UpdateLinksOnFileMoveSetting>(settingNames.enabled);
98+
switch (setting) {
99+
case UpdateLinksOnFileMoveSetting.Prompt:
100+
return this.promptUser(newResources);
101+
case UpdateLinksOnFileMoveSetting.Always:
102+
return true;
103+
case UpdateLinksOnFileMoveSetting.Never:
104+
default:
105+
return false;
106+
}
107+
}
108+
109+
private getConfiguration(resource: vscode.Uri) {
110+
return vscode.workspace.getConfiguration('markdown', resource);
111+
}
112+
113+
private shouldParticipateInLinkUpdate(config: vscode.WorkspaceConfiguration, newUri: vscode.Uri) {
114+
if (looksLikeMarkdownPath(newUri)) {
115+
return true;
116+
}
117+
118+
const externalGlob = config.get<string>(settingNames.externalFileGlobs);
119+
return !!externalGlob && picomatch.isMatch(newUri.fsPath, externalGlob);
120+
}
121+
122+
private async promptUser(newResources: readonly vscode.Uri[]): Promise<boolean> {
123+
if (!newResources.length) {
124+
return false;
125+
}
126+
127+
const enum Choice {
128+
None = 0,
129+
Accept = 1,
130+
Reject = 2,
131+
Always = 3,
132+
Never = 4,
133+
}
134+
135+
interface Item extends vscode.MessageItem {
136+
readonly choice: Choice;
137+
}
138+
139+
const response = await vscode.window.showInformationMessage<Item>(
140+
newResources.length === 1
141+
? localize('prompt', "Update Markdown links for '{0}'?", path.basename(newResources[0].fsPath))
142+
: this.getConfirmMessage(localize('promptMoreThanOne', "Update Markdown link for the following {0} files?", newResources.length), newResources), {
143+
modal: true,
144+
}, {
145+
title: localize('reject.title', "No"),
146+
choice: Choice.Reject,
147+
isCloseAffordance: true,
148+
}, {
149+
title: localize('accept.title', "Yes"),
150+
choice: Choice.Accept,
151+
}, {
152+
title: localize('always.title', "Always automatically update Markdown Links"),
153+
choice: Choice.Always,
154+
}, {
155+
title: localize('never.title', "Never automatically update Markdown Links"),
156+
choice: Choice.Never,
157+
});
158+
159+
if (!response) {
160+
return false;
161+
}
162+
163+
switch (response.choice) {
164+
case Choice.Accept: {
165+
return true;
166+
}
167+
case Choice.Reject: {
168+
return false;
169+
}
170+
case Choice.Always: {
171+
const config = this.getConfiguration(newResources[0]);
172+
config.update(
173+
settingNames.enabled,
174+
UpdateLinksOnFileMoveSetting.Always,
175+
vscode.ConfigurationTarget.Global);
176+
return true;
177+
}
178+
case Choice.Never: {
179+
const config = this.getConfiguration(newResources[0]);
180+
config.update(
181+
settingNames.enabled,
182+
UpdateLinksOnFileMoveSetting.Never,
183+
vscode.ConfigurationTarget.Global);
184+
return false;
185+
}
186+
}
187+
188+
return false;
189+
}
190+
191+
private async withEditsForFileRename(
192+
workspaceEdit: vscode.WorkspaceEdit,
193+
oldUri: vscode.Uri,
194+
newUri: vscode.Uri,
195+
token: vscode.CancellationToken,
196+
): Promise<boolean> {
197+
const edit = await this.client.sendRequest(getEditForFileRenames, [{ oldUri: oldUri.toString(), newUri: newUri.toString() }], token);
198+
if (!edit.changes) {
199+
return false;
200+
}
201+
202+
for (const [path, edits] of Object.entries(edit.changes)) {
203+
const uri = vscode.Uri.parse(path);
204+
for (const edit of edits) {
205+
workspaceEdit.replace(uri, convertRange(edit.range), edit.newText);
206+
}
207+
}
208+
209+
return true;
210+
}
211+
212+
private getConfirmMessage(start: string, resourcesToConfirm: readonly vscode.Uri[]): string {
213+
const MAX_CONFIRM_FILES = 10;
214+
215+
const paths = [start];
216+
paths.push('');
217+
paths.push(...resourcesToConfirm.slice(0, MAX_CONFIRM_FILES).map(r => path.basename(r.fsPath)));
218+
219+
if (resourcesToConfirm.length > MAX_CONFIRM_FILES) {
220+
if (resourcesToConfirm.length - MAX_CONFIRM_FILES === 1) {
221+
paths.push(localize('moreFile', "...1 additional file not shown"));
222+
} else {
223+
paths.push(localize('moreFiles', "...{0} additional files not shown", resourcesToConfirm.length - MAX_CONFIRM_FILES));
224+
}
225+
}
226+
227+
paths.push('');
228+
return paths.join('\n');
229+
}
230+
}
231+
232+
export function registerUpdatePathsOnRename(client: BaseLanguageClient) {
233+
return new UpdateImportsOnFileRenameHandler(client);
234+
}

extensions/markdown-language-features/src/protocol.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const findMarkdownFilesInWorkspace = new RequestType<{}, string[], any>('
2323

2424
//#region To server
2525
export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace');
26+
export const getEditForFileRenames = new RequestType<Array<{ oldUri: string; newUri: string }>, lsp.WorkspaceEdit, any>('markdown/getEditForFileRenames');
2627

2728
export const fs_watcher_onChange = new RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any>('markdown/fs/watcher/onChange');
2829
//#endregion

0 commit comments

Comments
 (0)