Files
vscode/extensions/markdown-language-features/src/languageFeatures/linkUpdater.ts
Matt Bierner 7df46143a1 Experiment with switching markdown extension to use native privates
Let's try this out with one extension to start
2026-03-10 23:13:16 -07:00

233 lines
7.0 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as picomatch from 'picomatch';
import * as vscode from 'vscode';
import { TextDocumentEdit } from 'vscode-languageclient';
import { Utils } from 'vscode-uri';
import { MdLanguageClient } from '../client/client';
import { Delayer } from '../util/async';
import { noopToken } from '../util/cancellation';
import { Disposable } from '../util/dispose';
import { convertRange } from './fileReferences';
const settingNames = Object.freeze({
enabled: 'updateLinksOnFileMove.enabled',
include: 'updateLinksOnFileMove.include',
enableForDirectories: 'updateLinksOnFileMove.enableForDirectories',
});
const enum UpdateLinksOnFileMoveSetting {
Prompt = 'prompt',
Always = 'always',
Never = 'never',
}
interface RenameAction {
readonly oldUri: vscode.Uri;
readonly newUri: vscode.Uri;
}
class UpdateLinksOnFileRenameHandler extends Disposable {
readonly #delayer = new Delayer(50);
readonly #pendingRenames = new Set<RenameAction>();
readonly #client: MdLanguageClient;
public constructor(
client: MdLanguageClient,
) {
super();
this.#client = client;
this._register(vscode.workspace.onDidRenameFiles(async (e) => {
await Promise.all(e.files.map(async (rename) => {
if (await this.#shouldParticipateInLinkUpdate(rename.newUri)) {
this.#pendingRenames.add(rename);
}
}));
if (this.#pendingRenames.size) {
this.#delayer.trigger(() => {
vscode.window.withProgress({
location: vscode.ProgressLocation.Window,
title: vscode.l10n.t("Checking for Markdown links to update")
}, () => this.#flushRenames());
});
}
}));
}
async #flushRenames(): Promise<void> {
const renames = Array.from(this.#pendingRenames);
this.#pendingRenames.clear();
const result = await this.#getEditsForFileRename(renames, noopToken);
if (result?.edit.size) {
if (await this.#confirmActionWithUser(result.resourcesBeingRenamed)) {
await vscode.workspace.applyEdit(result.edit);
}
}
}
async #confirmActionWithUser(newResources: readonly vscode.Uri[]): Promise<boolean> {
if (!newResources.length) {
return false;
}
const config = vscode.workspace.getConfiguration('markdown', newResources[0]);
const setting = config.get<UpdateLinksOnFileMoveSetting>(settingNames.enabled);
switch (setting) {
case UpdateLinksOnFileMoveSetting.Prompt:
return this.#promptUser(newResources);
case UpdateLinksOnFileMoveSetting.Always:
return true;
case UpdateLinksOnFileMoveSetting.Never:
default:
return false;
}
}
async #shouldParticipateInLinkUpdate(newUri: vscode.Uri): Promise<boolean> {
const config = vscode.workspace.getConfiguration('markdown', newUri);
const setting = config.get<UpdateLinksOnFileMoveSetting>(settingNames.enabled);
if (setting === UpdateLinksOnFileMoveSetting.Never) {
return false;
}
const externalGlob = config.get<string[]>(settingNames.include);
if (externalGlob) {
for (const glob of externalGlob) {
if (picomatch.isMatch(newUri.fsPath, glob)) {
return true;
}
}
}
const stat = await vscode.workspace.fs.stat(newUri);
if (stat.type === vscode.FileType.Directory) {
return config.get<boolean>(settingNames.enableForDirectories, true);
}
return false;
}
async #promptUser(newResources: readonly vscode.Uri[]): Promise<boolean> {
if (!newResources.length) {
return false;
}
const rejectItem: vscode.MessageItem = {
title: vscode.l10n.t("No"),
isCloseAffordance: true,
};
const acceptItem: vscode.MessageItem = {
title: vscode.l10n.t("Yes"),
};
const alwaysItem: vscode.MessageItem = {
title: vscode.l10n.t("Always"),
};
const neverItem: vscode.MessageItem = {
title: vscode.l10n.t("Never"),
};
const choice = await vscode.window.showInformationMessage(
newResources.length === 1
? vscode.l10n.t("Update Markdown links for '{0}'?", Utils.basename(newResources[0]))
: this.#getConfirmMessage(vscode.l10n.t("Update Markdown links for the following {0} files?", newResources.length), newResources), {
modal: true,
}, rejectItem, acceptItem, alwaysItem, neverItem);
switch (choice) {
case acceptItem: {
return true;
}
case rejectItem: {
return false;
}
case alwaysItem: {
const config = vscode.workspace.getConfiguration('markdown', newResources[0]);
config.update(
settingNames.enabled,
UpdateLinksOnFileMoveSetting.Always,
this.#getConfigTargetScope(config, settingNames.enabled));
return true;
}
case neverItem: {
const config = vscode.workspace.getConfiguration('markdown', newResources[0]);
config.update(
settingNames.enabled,
UpdateLinksOnFileMoveSetting.Never,
this.#getConfigTargetScope(config, settingNames.enabled));
return false;
}
default: {
return false;
}
}
}
async #getEditsForFileRename(renames: readonly RenameAction[], token: vscode.CancellationToken): Promise<{ edit: vscode.WorkspaceEdit; resourcesBeingRenamed: vscode.Uri[] } | undefined> {
const result = await this.#client.getEditForFileRenames(renames.map(rename => ({ oldUri: rename.oldUri.toString(), newUri: rename.newUri.toString() })), token);
if (!result?.edit.documentChanges?.length) {
return undefined;
}
const workspaceEdit = new vscode.WorkspaceEdit();
for (const change of result.edit.documentChanges as TextDocumentEdit[]) {
const uri = vscode.Uri.parse(change.textDocument.uri);
for (const edit of change.edits) {
workspaceEdit.replace(uri, convertRange(edit.range), edit.newText);
}
}
return {
edit: workspaceEdit,
resourcesBeingRenamed: result.participatingRenames.map(x => vscode.Uri.parse(x.newUri)),
};
}
#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 => Utils.basename(r)));
if (resourcesToConfirm.length > MAX_CONFIRM_FILES) {
if (resourcesToConfirm.length - MAX_CONFIRM_FILES === 1) {
paths.push(vscode.l10n.t("...1 additional file not shown"));
} else {
paths.push(vscode.l10n.t("...{0} additional files not shown", resourcesToConfirm.length - MAX_CONFIRM_FILES));
}
}
paths.push('');
return paths.join('\n');
}
#getConfigTargetScope(config: vscode.WorkspaceConfiguration, settingsName: string): vscode.ConfigurationTarget {
const inspected = config.inspect(settingsName);
if (inspected?.workspaceFolderValue) {
return vscode.ConfigurationTarget.WorkspaceFolder;
}
if (inspected?.workspaceValue) {
return vscode.ConfigurationTarget.Workspace;
}
return vscode.ConfigurationTarget.Global;
}
}
export function registerUpdateLinksOnRename(client: MdLanguageClient): vscode.Disposable {
return new UpdateLinksOnFileRenameHandler(client);
}