diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 09351856a62..f78d2158ac8 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -571,6 +571,13 @@ "default": false, "description": "%configuration.markdown.occurrencesHighlight.enabled%", "scope": "resource" + }, + "markdown.experimental.copyFiles.destination": { + "type": "object", + "markdownDescription": "%configuration.markdown.copyFiles.destination%", + "additionalProperties": { + "type": "string" + } } } }, diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index 676d875d3b9..42c030abb10 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -49,5 +49,6 @@ "configuration.markdown.updateLinksOnFileMove.include.property": "The glob pattern to match file paths against. Set to true to enable the pattern.", "configuration.markdown.updateLinksOnFileMove.enableForDirectories": "Enable updating links when a directory is moved or renamed in the workspace.", "configuration.markdown.occurrencesHighlight.enabled": "Enable highlighting link occurrences in the current document.", + "configuration.markdown.copyFiles.destination": "Defines where files copied into a Markdown document should be created. This is a map from globs that match on the Markdown document to destinations.\n\nThe destinations may use the following variables:\n\n- `${documentFileName}` — The full filename of the Markdown document, for example `readme.md`.\n- `${documentBaseName}` — The basename of Markdown document, for example `readme`.\n- `${documentExtName}` — The extension of the Markdown document, for example `md`.\n- `${documentDirName}` — The name of the Markdown document's parent directory.\n- `${documentWorkspaceFolder}` — The workspace folder for the Markdown document, for examples, `/Users/me/myProject`. This is the same as `${documentDirName}` if the file is not part of in a workspace.\n- `${fileName}` — The file name of the dropped file, for example `image.png`.", "workspaceTrust": "Required for loading styles configured in the workspace." } diff --git a/extensions/markdown-language-features/src/commands/insertResource.ts b/extensions/markdown-language-features/src/commands/insertResource.ts index 66041df75d3..7cdd861cb5b 100644 --- a/extensions/markdown-language-features/src/commands/insertResource.ts +++ b/extensions/markdown-language-features/src/commands/insertResource.ts @@ -6,12 +6,11 @@ import * as vscode from 'vscode'; import { Utils } from 'vscode-uri'; import { Command } from '../commandManager'; -import { createUriListSnippet, getParentDocumentUri, imageFileExtensions } from '../languageFeatures/dropIntoEditor'; +import { createUriListSnippet, getParentDocumentUri, imageFileExtensions } from '../languageFeatures/copyFiles/dropIntoEditor'; import { coalesce } from '../util/arrays'; import { Schemes } from '../util/schemes'; - export class InsertLinkFromWorkspace implements Command { public readonly id = 'markdown.editor.insertLinkFromWorkspace'; diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index dbfafc2bdb5..ce709783d0b 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -7,9 +7,9 @@ import * as vscode from 'vscode'; import { MdLanguageClient } from './client/client'; import { CommandManager } from './commandManager'; import { registerMarkdownCommands } from './commands/index'; -import { registerPasteSupport } from './languageFeatures/copyPaste'; +import { registerPasteSupport } from './languageFeatures/copyFiles/copyPaste'; import { registerDiagnosticSupport } from './languageFeatures/diagnostics'; -import { registerDropIntoEditorSupport } from './languageFeatures/dropIntoEditor'; +import { registerDropIntoEditorSupport } from './languageFeatures/copyFiles/dropIntoEditor'; import { registerFindFileReferenceSupport } from './languageFeatures/fileReferences'; import { registerUpdateLinksOnRename } from './languageFeatures/linkUpdater'; import { ILogger } from './logging'; diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts new file mode 100644 index 00000000000..fe435e0795b --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Utils } from 'vscode-uri'; +import { getParentDocumentUri } from './dropIntoEditor'; + + +export async function getNewFileName(document: vscode.TextDocument, file: vscode.DataTransferFile): Promise { + const desiredPath = getDesiredNewFilePath(document, file); + + const root = Utils.dirname(desiredPath); + const ext = path.extname(file.name); + const baseName = path.basename(file.name, ext); + for (let i = 0; ; ++i) { + const name = i === 0 ? baseName : `${baseName}-${i}`; + const uri = vscode.Uri.joinPath(root, `${name}${ext}`); + try { + await vscode.workspace.fs.stat(uri); + } catch { + // Does not exist + return uri; + } + } +} + +function getDesiredNewFilePath(document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri { + const docUri = getParentDocumentUri(document); + const config = vscode.workspace.getConfiguration('markdown').get>('experimental.copyFiles.destination') ?? {}; + for (const [rawGlob, rawDest] of Object.entries(config)) { + for (const glob of parseGlob(rawGlob)) { + if (picomatch.isMatch(docUri.path, glob)) { + return resolveCopyDestination(docUri, file.name, rawDest, uri => vscode.workspace.getWorkspaceFolder(uri)?.uri); + } + } + } + + // Default to next to current file + return vscode.Uri.joinPath(Utils.dirname(docUri), file.name); +} + +function parseGlob(rawGlob: string): Iterable { + if (rawGlob.startsWith('/')) { + // Anchor to workspace folders + return (vscode.workspace.workspaceFolders ?? []).map(folder => vscode.Uri.joinPath(folder.uri, rawGlob).path); + } + + // Relative path, so implicitly track on ** to match everything + if (!rawGlob.startsWith('**')) { + return ['**/' + rawGlob]; + } + + return [rawGlob]; +} + +type GetWorkspaceFolder = (documentUri: vscode.Uri) => vscode.Uri | undefined; + +export function resolveCopyDestination(documentUri: vscode.Uri, fileName: string, dest: string, getWorkspaceFolder: GetWorkspaceFolder): vscode.Uri { + const resolvedDest = resolveCopyDestinationSetting(documentUri, fileName, dest, getWorkspaceFolder); + + if (resolvedDest.startsWith('/')) { + // Absolute path + return Utils.resolvePath(documentUri, resolvedDest); + } + + // Relative to document + const dirName = Utils.dirname(documentUri); + return Utils.resolvePath(dirName, resolvedDest); +} + + +function resolveCopyDestinationSetting(documentUri: vscode.Uri, fileName: string, dest: string, getWorkspaceFolder: GetWorkspaceFolder): string { + let outDest = dest; + + // Destination that start with `/` implicitly means go to workspace root + if (outDest.startsWith('/')) { + outDest = '${documentWorkspaceFolder}/' + outDest.slice(1); + } + + // Destination that ends with `/` implicitly needs a fileName + if (outDest.endsWith('/')) { + outDest += '${fileName}'; + } + + const documentDirName = Utils.dirname(documentUri); + const documentBaseName = Utils.basename(documentUri); + const documentExtName = Utils.extname(documentUri); + + const workspaceFolder = getWorkspaceFolder(documentUri); + + const vars = new Map([ + ['documentDirName', documentDirName.fsPath], // Parent directory path + ['documentFileName', documentBaseName], // Full filename: file.md + ['documentBaseName', documentBaseName.slice(0, documentBaseName.length - documentExtName.length)], // Just the name: file + ['documentExtName', documentExtName.replace('.', '')], // Just the file ext: md + + // Workspace + ['documentWorkspaceFolder', (workspaceFolder ?? documentDirName).fsPath], + + // File + ['fileName', fileName],// Full file name + ]); + + return outDest.replaceAll(/\$\{(\w+)(?:\/([^\}]+?)\/([^\}]+?)\/)?\}/g, (_, name, pattern, replacement) => { + const entry = vars.get(name); + if (!entry) { + return ''; + } + + if (pattern && replacement) { + return entry.replace(new RegExp(pattern), replacement); + } + + return entry; + }); +} diff --git a/extensions/markdown-language-features/src/languageFeatures/copyPaste.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts similarity index 80% rename from extensions/markdown-language-features/src/languageFeatures/copyPaste.ts rename to extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts index 0a39f9fabb4..1babe16107d 100644 --- a/extensions/markdown-language-features/src/languageFeatures/copyPaste.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/copyPaste.ts @@ -3,10 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; import * as vscode from 'vscode'; -import { Utils } from 'vscode-uri'; -import { Schemes } from '../util/schemes'; +import { Schemes } from '../../util/schemes'; +import { getNewFileName } from './copyFiles'; import { createUriListSnippet, tryGetUriListSnippet } from './dropIntoEditor'; const supportedImageMimes = new Set([ @@ -59,7 +58,7 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider { } } - const uri = await this._getNewFileName(document, file); + const uri = await getNewFileName(document, file); if (token.isCancellationRequested) { return; } @@ -77,23 +76,6 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider { pasteEdit.additionalEdit = workspaceEdit; return pasteEdit; } - - private async _getNewFileName(document: vscode.TextDocument, file: vscode.DataTransferFile): Promise { - const root = Utils.dirname(document.uri); - - const ext = path.extname(file.name); - const baseName = path.basename(file.name, ext); - for (let i = 0; ; ++i) { - const name = i === 0 ? baseName : `${baseName}-${i}`; - const uri = vscode.Uri.joinPath(root, `${name}${ext}`); - try { - await vscode.workspace.fs.stat(uri); - } catch { - // Does not exist - return uri; - } - } - } } export function registerPasteSupport(selector: vscode.DocumentSelector,) { diff --git a/extensions/markdown-language-features/src/languageFeatures/dropIntoEditor.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts similarity index 98% rename from extensions/markdown-language-features/src/languageFeatures/dropIntoEditor.ts rename to extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts index 4f8c6593ee2..e91d9d11008 100644 --- a/extensions/markdown-language-features/src/languageFeatures/dropIntoEditor.ts +++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropIntoEditor.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as URI from 'vscode-uri'; -import { Schemes } from '../util/schemes'; +import { Schemes } from '../../util/schemes'; export const imageFileExtensions = new Set([ 'bmp', diff --git a/extensions/markdown-language-features/src/test/copyFile.test.ts b/extensions/markdown-language-features/src/test/copyFile.test.ts new file mode 100644 index 00000000000..e557d29a04e --- /dev/null +++ b/extensions/markdown-language-features/src/test/copyFile.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import * as vscode from 'vscode'; +import { resolveCopyDestination } from '../languageFeatures/copyFiles/copyFiles'; + + +suite.only('resolveCopyDestination', () => { + + test('Relative destinations should resolve next to document', async () => { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + + { + const dest = resolveCopyDestination(documentUri, 'img.png', '${fileName}', () => vscode.Uri.parse('test://projects/project/')); + assert.strictEqual(dest.toString(), 'test://projects/project/sub/img.png'); + } + { + const dest = resolveCopyDestination(documentUri, 'img.png', './${fileName}', () => vscode.Uri.parse('test://projects/project/')); + assert.strictEqual(dest.toString(), 'test://projects/project/sub/img.png'); + } + { + const dest = resolveCopyDestination(documentUri, 'img.png', '../${fileName}', () => vscode.Uri.parse('test://projects/project/')); + assert.strictEqual(dest.toString(), 'test://projects/project/img.png'); + } + }); + + test('Destination starting with / should go to workspace root', async () => { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + const dest = resolveCopyDestination(documentUri, 'img.png', '/${fileName}', () => vscode.Uri.parse('test://projects/project/')); + + assert.strictEqual(dest.toString(), 'test://projects/project/img.png'); + }); + + test('If there is no workspace root, / should resolve to document dir', async () => { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + const dest = resolveCopyDestination(documentUri, 'img.png', '/${fileName}', () => undefined); + + assert.strictEqual(dest.toString(), 'test://projects/project/sub/img.png'); + }); + + test('If path ends in /, we should automatically add the fileName', async () => { + { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + const dest = resolveCopyDestination(documentUri, 'img.png', 'images/', () => vscode.Uri.parse('test://projects/project/')); + assert.strictEqual(dest.toString(), 'test://projects/project/sub/images/img.png'); + } + { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + const dest = resolveCopyDestination(documentUri, 'img.png', './', () => vscode.Uri.parse('test://projects/project/')); + assert.strictEqual(dest.toString(), 'test://projects/project/sub/img.png'); + } + { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + const dest = resolveCopyDestination(documentUri, 'img.png', '/', () => vscode.Uri.parse('test://projects/project/')); + + assert.strictEqual(dest.toString(), 'test://projects/project/img.png'); + } + }); + + test('Basic transform', async () => { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + const dest = resolveCopyDestination(documentUri, 'img.png', '${fileName/.png/.gif/}', () => undefined); + + assert.strictEqual(dest.toString(), 'test://projects/project/sub/img.gif'); + }); + + test('transforms should support capture groups', async () => { + const documentUri = vscode.Uri.parse('test://projects/project/sub/readme.md'); + const dest = resolveCopyDestination(documentUri, 'img.png', '${fileName/(.+)\\.(.+)/$2.$1/}', () => undefined); + + assert.strictEqual(dest.toString(), 'test://projects/project/sub/png.img'); + }); +});