Add support for renaming files in markdown using F2

This lets you trigger F2 on a file path in a markdown link to both rename the file and also update all references to it
This commit is contained in:
Matt Bierner
2022-04-20 16:38:30 -07:00
parent 9fbd962973
commit 0ac39e800d
5 changed files with 304 additions and 63 deletions

View File

@@ -176,22 +176,14 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
return references;
}
let targetDoc = await this.workspaceContents.getMarkdownDocument(sourceLink.href.path);
if (!targetDoc) {
// We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead
if (uri.Utils.extname(sourceLink.href.path) === '') {
const dotMdResource = sourceLink.href.path.with({ path: sourceLink.href.path.path + '.md' });
targetDoc = await this.workspaceContents.getMarkdownDocument(dotMdResource);
}
}
if (!targetDoc || token.isCancellationRequested) {
const targetDoc = await tryFindMdDocumentForLink(sourceLink.href, this.workspaceContents);
if (token.isCancellationRequested) {
return [];
}
const references: MdReference[] = [];
if (sourceLink.href.fragment && sourceLink.source.fragmentRange?.contains(triggerPosition)) {
if (targetDoc && sourceLink.href.fragment && sourceLink.source.fragmentRange?.contains(triggerPosition)) {
const toc = await TableOfContents.create(this.engine, targetDoc);
const entry = toc.lookup(sourceLink.href.fragment);
if (entry) {
@@ -222,7 +214,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
}
}
} else { // Triggered on a link without a fragment so we only require matching the file and ignore fragments
references.push(...this.findAllLinksToFile(targetDoc.uri, allLinksInWorkspace, sourceLink));
references.push(...this.findAllLinksToFile(targetDoc?.uri ?? sourceLink.href.path, allLinksInWorkspace, sourceLink));
}
return references;
@@ -297,3 +289,19 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
: link.source.hrefRange;
}
}
export async function tryFindMdDocumentForLink(href: InternalHref, workspaceContents: MdWorkspaceContents): Promise<SkinnyTextDocument | undefined> {
const targetDoc = await workspaceContents.getMarkdownDocument(href.path);
if (targetDoc) {
return targetDoc;
}
// We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead
if (uri.Utils.extname(href.path) === '') {
const dotMdResource = href.path.with({ path: href.path.path + '.md' });
return workspaceContents.getMarkdownDocument(dotMdResource);
}
return undefined;
}

View File

@@ -6,12 +6,35 @@ import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { Slugifier } from '../slugify';
import { Disposable } from '../util/dispose';
import { SkinnyTextDocument } from '../workspaceContents';
import { MdHeaderReference, MdReference, MdReferencesProvider } from './references';
import { resolveDocumentLink } from '../util/openDocumentLink';
import { MdWorkspaceContents, SkinnyTextDocument } from '../workspaceContents';
import { InternalHref } from './documentLinkProvider';
import { MdHeaderReference, MdLinkReference, MdReference, MdReferencesProvider, tryFindMdDocumentForLink } from './references';
const localize = nls.loadMessageBundle();
export interface MdReferencesResponse {
references: MdReference[];
triggerRef: MdReference;
}
interface MdFileRenameEdit {
readonly from: string;
readonly to: string;
}
/**
* Type with additional metadata about the edits for testing
*
* This is needed since `vscode.WorkspaceEdit` does not expose info on file renames.
*/
export interface MdWorkspaceEdit {
readonly edit: vscode.WorkspaceEdit;
readonly fileRenames?: ReadonlyArray<MdFileRenameEdit>;
}
export class MdRenameProvider extends Disposable implements vscode.RenameProvider {
private cachedRefs?: {
@@ -22,8 +45,11 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
readonly references: MdReference[];
} | undefined;
private readonly renameNotSupportedText = localize('invalidRenameLocation', "Rename not supported at location");
public constructor(
private readonly referencesProvider: MdReferencesProvider,
private readonly workspaceContents: MdWorkspaceContents,
private readonly slugifier: Slugifier,
) {
super();
@@ -36,7 +62,7 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
}
if (!allRefsInfo || !allRefsInfo.references.length) {
throw new Error(localize('invalidRenameLocation', "Rename not supported at location"));
throw new Error(this.renameNotSupportedText);
}
const triggerRef = allRefsInfo.triggerRef;
@@ -56,8 +82,9 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
return { range: triggerRef.link.source.hrefRange, placeholder: document.getText(triggerRef.link.source.hrefRange) };
}
// See if we are renaming the fragment or the path
const { fragmentRange } = triggerRef.link.source;
if (fragmentRange) {
if (fragmentRange?.contains(position)) {
const declaration = this.findHeaderDeclaration(allRefsInfo.references);
if (declaration) {
return { range: fragmentRange, placeholder: declaration.headerText };
@@ -65,16 +92,31 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
return { range: fragmentRange, placeholder: document.getText(fragmentRange) };
}
throw new Error(localize('renameNoFiles', "Renaming files is currently not supported"));
const range = this.getFilePathRange(triggerRef);
if (!range) {
throw new Error(this.renameNotSupportedText);
}
return { range, placeholder: document.getText(range) };
}
}
}
private getFilePathRange(ref: MdLinkReference): vscode.Range {
if (ref.link.source.fragmentRange) {
return ref.link.source.hrefRange.with(undefined, ref.link.source.fragmentRange.start.translate(0, -1));
}
return ref.link.source.hrefRange;
}
private findHeaderDeclaration(references: readonly MdReference[]): MdHeaderReference | undefined {
return references.find(ref => ref.isDefinition && ref.kind === 'header') as MdHeaderReference | undefined;
}
public async provideRenameEdits(document: SkinnyTextDocument, position: vscode.Position, newName: string, token: vscode.CancellationToken): Promise<vscode.WorkspaceEdit | undefined> {
return (await this.provideRenameEditsImpl(document, position, newName, token))?.edit;
}
public async provideRenameEditsImpl(document: SkinnyTextDocument, position: vscode.Position, newName: string, token: vscode.CancellationToken): Promise<MdWorkspaceEdit | undefined> {
const allRefsInfo = await this.getAllReferences(document, position, token);
if (token.isCancellationRequested || !allRefsInfo || !allRefsInfo.references.length) {
return undefined;
@@ -82,9 +124,44 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
const triggerRef = allRefsInfo.triggerRef;
const isRefRename = triggerRef.kind === 'link' && (
if (triggerRef.kind === 'link' && (
(triggerRef.link.kind === 'definition' && triggerRef.link.ref.range.contains(position)) || triggerRef.link.href.kind === 'reference'
);
)) {
return this.renameReferenceLinks(allRefsInfo, newName);
} else if (triggerRef.kind === 'link' && triggerRef.link.href.kind === 'external') {
return this.renameExternalLink(allRefsInfo, newName);
} else if (triggerRef.kind === 'header' || (triggerRef.kind === 'link' && triggerRef.link.source.fragmentRange?.contains(position) && (triggerRef.link.kind === 'definition' || triggerRef.link.kind === 'link' && triggerRef.link.href.kind === 'internal'))) {
return this.renameFragment(allRefsInfo, newName);
} else if (triggerRef.kind === 'link' && !triggerRef.link.source.fragmentRange?.contains(position) && triggerRef.link.kind === 'link' && triggerRef.link.href.kind === 'internal') {
return this.renameFilePath(triggerRef.link.href, allRefsInfo, newName);
}
return undefined;
}
private async renameFilePath(triggerHref: InternalHref, allRefsInfo: MdReferencesResponse, newName: string): Promise<MdWorkspaceEdit> {
const edit = new vscode.WorkspaceEdit();
const fileRenames: MdFileRenameEdit[] = [];
const targetDoc = await tryFindMdDocumentForLink(triggerHref, this.workspaceContents);
const targetUri = targetDoc?.uri ?? triggerHref.path;
const newFilePath = resolveDocumentLink(newName, triggerHref.path);
// First rename the file
fileRenames.push({ from: targetUri.toString(), to: newFilePath.toString() });
edit.renameFile(targetUri, newFilePath);
// Then update all refs to it
for (const ref of allRefsInfo.references) {
if (ref.kind === 'link') {
edit.replace(ref.link.source.resource, this.getFilePathRange(ref), encodeURI(newName));
}
}
return { edit, fileRenames };
}
private renameFragment(allRefsInfo: MdReferencesResponse, newName: string): MdWorkspaceEdit {
const slug = this.slugifier.fromHeading(newName).value;
const edit = new vscode.WorkspaceEdit();
@@ -95,22 +172,38 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide
break;
case 'link':
if (ref.link.kind === 'definition') {
// We may be renaming either the reference or the definition itself
if (isRefRename) {
edit.replace(ref.link.source.resource, ref.link.ref.range, newName);
continue;
}
}
edit.replace(ref.link.source.resource, ref.link.source.fragmentRange ?? ref.location.range, isRefRename && !ref.link.source.fragmentRange || ref.link.href.kind === 'external' ? newName : slug);
edit.replace(ref.link.source.resource, ref.link.source.fragmentRange ?? ref.location.range, !ref.link.source.fragmentRange || ref.link.href.kind === 'external' ? newName : slug);
break;
}
}
return edit;
return { edit };
}
private async getAllReferences(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<{ references: MdReference[]; triggerRef: MdReference } | undefined> {
private renameExternalLink(allRefsInfo: MdReferencesResponse, newName: string): MdWorkspaceEdit {
const edit = new vscode.WorkspaceEdit();
for (const ref of allRefsInfo.references) {
if (ref.kind === 'link') {
edit.replace(ref.link.source.resource, ref.location.range, newName);
}
}
return { edit };
}
private renameReferenceLinks(allRefsInfo: MdReferencesResponse, newName: string): MdWorkspaceEdit {
const edit = new vscode.WorkspaceEdit();
for (const ref of allRefsInfo.references) {
if (ref.kind === 'link') {
if (ref.link.kind === 'definition') {
edit.replace(ref.link.source.resource, ref.link.ref.range, newName);
} else {
edit.replace(ref.link.source.resource, ref.link.source.fragmentRange ?? ref.location.range, newName);
}
}
}
return { edit };
}
private async getAllReferences(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReferencesResponse | undefined> {
const version = document.version;
if (this.cachedRefs