/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. 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 { TextDocumentEdit } from 'vscode-languageclient'; 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 { private readonly _delayer = new Delayer(50); private readonly _pendingRenames = new Set(); public constructor( private readonly _client: MdLanguageClient, ) { super(); 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()); }); } })); } private async _flushRenames(): Promise { const renames = Array.from(this._pendingRenames); this._pendingRenames.clear(); const result = await this._getEditsForFileRename(renames, noopToken); if (result && result.edit.size) { if (await this._confirmActionWithUser(result.resourcesBeingRenamed)) { await vscode.workspace.applyEdit(result.edit); } } } private async _confirmActionWithUser(newResources: readonly vscode.Uri[]): Promise { if (!newResources.length) { return false; } const config = vscode.workspace.getConfiguration('markdown', 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 async _shouldParticipateInLinkUpdate(newUri: vscode.Uri): Promise { const config = vscode.workspace.getConfiguration('markdown', newUri); const setting = config.get(settingNames.enabled); if (setting === UpdateLinksOnFileMoveSetting.Never) { return false; } const externalGlob = config.get(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(settingNames.enableForDirectories, true); } return false; } private async _promptUser(newResources: readonly vscode.Uri[]): Promise { 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}'?", path.basename(newResources[0].fsPath)) : 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; } } } private 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)), }; } 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(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'); } private _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); }