Rework markdown paste resource (#201838)

Fixes #184980

This refactors much of the logic around markdown paste/drop. PR got a little large but the main highlights are:

- Allow using a custom snippet for inserted audio/video
- Merge the drop/paste resource provider classes since these are so similar
- Enable smart pasting of url text by default
- Refactor url paste logic
- For now, disable the behavior where url paste could paste a combination of markdown and plain uris. In practice this is confusing, especially because our labels for this were wrong. We can always reintroduce this later if multicursor users find it useful
This commit is contained in:
Matt Bierner 2024-01-04 15:59:14 -08:00 committed by GitHub
parent ee91ce84bb
commit 26ef59c6a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 622 additions and 546 deletions

View file

@ -503,11 +503,21 @@
"%configuration.copyIntoWorkspace.never%"
]
},
"markdown.editor.filePaste.videoSnippet": {
"type": "string",
"markdownDescription": "%configuration.markdown.editor.filePaste.videoSnippet%",
"default": "<video controls src=\"${src}\" title=\"${title}\"></video>"
},
"markdown.editor.filePaste.audioSnippet": {
"type": "string",
"markdownDescription": "%configuration.markdown.editor.filePaste.audioSnippet%",
"default": "<audio controls src=\"${src}\" title=\"${title}\"></audio>"
},
"markdown.editor.pasteUrlAsFormattedLink.enabled": {
"type": "string",
"scope": "resource",
"markdownDescription": "%configuration.markdown.editor.pasteUrlAsFormattedLink.enabled%",
"default": "never",
"default": "smart",
"enum": [
"always",
"smart",

View file

@ -43,10 +43,10 @@
"configuration.markdown.editor.filePaste.copyIntoWorkspace": "Controls if files outside of the workspace that are pasted into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied files should be created.",
"configuration.copyIntoWorkspace.mediaFiles": "Try to copy external image and video files into the workspace.",
"configuration.copyIntoWorkspace.never": "Do not copy external files into the workspace.",
"configuration.markdown.editor.pasteUrlAsFormattedLink.enabled": "Controls how a Markdown link is created when a URL is pasted into the Markdown editor. Requires enabling `#editor.pasteAs.enabled#`.",
"configuration.pasteUrlAsFormattedLink.always": "Always creates a Markdown link when a URL is pasted into the Markdown editor.",
"configuration.pasteUrlAsFormattedLink.smart": "Smartly avoids creating a Markdown link in specific cases, such as within code brackets or inside an existing Markdown link.",
"configuration.pasteUrlAsFormattedLink.never": "Never creates a Markdown link when a URL is pasted into the Markdown editor.",
"configuration.markdown.editor.pasteUrlAsFormattedLink.enabled": "Controls if Markdown links are created when URLs are pasted into a Markdown editor. Requires enabling `#editor.pasteAs.enabled#`.",
"configuration.pasteUrlAsFormattedLink.always": "Always insert Markdown links.",
"configuration.pasteUrlAsFormattedLink.smart": "Smartly create Markdown links by default when you have selected text and are not pasting into a code block or other special element. Use the paste widget to switch between pasting as plain text or as Markdown links.",
"configuration.pasteUrlAsFormattedLink.never": "Never create Markdown links.",
"configuration.markdown.validate.enabled.description": "Enable all error reporting in Markdown files.",
"configuration.markdown.validate.referenceLinks.enabled.description": "Validate reference links in Markdown files, for example: `[link][ref]`. Requires enabling `#markdown.validate.enabled#`.",
"configuration.markdown.validate.fragmentLinks.enabled.description": "Validate fragment links to headers in the current Markdown file, for example: `[link](#header)`. Requires enabling `#markdown.validate.enabled#`.",
@ -71,5 +71,7 @@
"configuration.markdown.preferredMdPathExtensionStyle.auto": "For existing paths, try to maintain the file extension style. For new paths, add file extensions.",
"configuration.markdown.preferredMdPathExtensionStyle.includeExtension": "Prefer including the file extension. For example, path completions to a file named `file.md` will insert `file.md`.",
"configuration.markdown.preferredMdPathExtensionStyle.removeExtension": "Prefer removing the file extension. For example, path completions to a file named `file.md` will insert `file` without the `.md`.",
"configuration.markdown.editor.filePaste.videoSnippet": "Snippet used when adding videos to Markdown. This snippet can use the following variables:\n- `${src}` — The resolved path of the video file.\n- `${title}` — The title used for the video. A snippet placeholder will automatically be created for this variable.",
"configuration.markdown.editor.filePaste.audioSnippet": "Snippet used when adding audio to Markdown. This snippet can use the following variables:\n- `${src}` — The resolved path of the audio file.\n- `${title}` — The title used for the audio. A snippet placeholder will automatically be created for this variable.",
"workspaceTrust": "Required for loading styles configured in the workspace."
}

View file

@ -29,8 +29,11 @@ export class InsertLinkFromWorkspace implements Command {
title: vscode.l10n.t("Insert link"),
defaultUri: getDefaultUri(activeEditor.document),
});
if (!resources) {
return;
}
return insertLink(activeEditor, resources ?? [], false);
return insertLink(activeEditor, resources, false);
}
}
@ -54,8 +57,11 @@ export class InsertImageFromWorkspace implements Command {
title: vscode.l10n.t("Insert image"),
defaultUri: getDefaultUri(activeEditor.document),
});
if (!resources) {
return;
}
return insertLink(activeEditor, resources ?? [], true);
return insertLink(activeEditor, resources, true);
}
}
@ -67,20 +73,18 @@ function getDefaultUri(document: vscode.TextDocument) {
return Utils.dirname(docUri);
}
async function insertLink(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsImage: boolean): Promise<void> {
if (!selectedFiles.length) {
return;
async function insertLink(activeEditor: vscode.TextEditor, selectedFiles: readonly vscode.Uri[], insertAsMedia: boolean): Promise<void> {
const edit = createInsertLinkEdit(activeEditor, selectedFiles, insertAsMedia);
if (edit) {
await vscode.workspace.applyEdit(edit);
}
const edit = createInsertLinkEdit(activeEditor, selectedFiles, insertAsImage);
await vscode.workspace.applyEdit(edit);
}
function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsMedia: boolean, title = '', placeholderValue = 0, pasteAsMarkdownLink = true, isExternalLink = false) {
function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: readonly vscode.Uri[], insertAsMedia: boolean) {
const snippetEdits = coalesce(activeEditor.selections.map((selection, i): vscode.SnippetTextEdit | undefined => {
const selectionText = activeEditor.document.getText(selection);
const snippet = createUriListSnippet(activeEditor.document, selectedFiles.map(uri => ({ uri })), title, placeholderValue, pasteAsMarkdownLink, isExternalLink, {
insertAsMedia,
const snippet = createUriListSnippet(activeEditor.document.uri, selectedFiles.map(uri => ({ uri })), {
insertAsMedia: insertAsMedia,
placeholderText: selectionText,
placeholderStartIndex: (i + 1) * selectedFiles.length,
separator: insertAsMedia ? '\n' : ' ',
@ -88,6 +92,9 @@ function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vs
return snippet ? new vscode.SnippetTextEdit(selection, snippet.snippet) : undefined;
}));
if (!snippetEdits.length) {
return;
}
const edit = new vscode.WorkspaceEdit();
edit.set(activeEditor.document.uri, snippetEdits);

View file

@ -7,10 +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/copyFiles/pasteResourceProvider';
import { registerLinkPasteSupport } from './languageFeatures/copyFiles/pasteUrlProvider';
import { registerResourceDropOrPasteSupport } from './languageFeatures/copyFiles/dropOrPasteResource';
import { registerDiagnosticSupport } from './languageFeatures/diagnostics';
import { registerDropIntoEditorSupport } from './languageFeatures/copyFiles/dropResourceProvider';
import { registerFindFileReferenceSupport } from './languageFeatures/fileReferences';
import { registerUpdateLinksOnRename } from './languageFeatures/linkUpdater';
import { ILogger } from './logging';
@ -57,9 +56,8 @@ function registerMarkdownLanguageFeatures(
return vscode.Disposable.from(
// Language features
registerDiagnosticSupport(selector, commandManager),
registerDropIntoEditorSupport(selector),
registerFindFileReferenceSupport(commandManager, client),
registerPasteSupport(selector),
registerResourceDropOrPasteSupport(selector),
registerLinkPasteSupport(selector),
registerUpdateLinksOnRename(client),
);

View file

@ -3,19 +3,17 @@
* 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 '../../util/document';
type OverwriteBehavior = 'overwrite' | 'nameIncrementally';
interface CopyFileConfiguration {
export interface CopyFileConfiguration {
readonly destination: Record<string, string>;
readonly overwriteBehavior: OverwriteBehavior;
}
function getCopyFileConfiguration(document: vscode.TextDocument): CopyFileConfiguration {
export function getCopyFileConfiguration(document: vscode.TextDocument): CopyFileConfiguration {
const config = vscode.workspace.getConfiguration('markdown', document);
return {
destination: config.get<Record<string, string>>('copyFiles.destination') ?? {},
@ -30,72 +28,7 @@ function readOverwriteBehavior(config: vscode.WorkspaceConfiguration): Overwrite
}
}
export class NewFilePathGenerator {
private readonly _usedPaths = new Set<string>();
async getNewFilePath(
document: vscode.TextDocument,
file: vscode.DataTransferFile,
token: vscode.CancellationToken,
): Promise<{ readonly uri: vscode.Uri; readonly overwrite: boolean } | undefined> {
const config = getCopyFileConfiguration(document);
const desiredPath = getDesiredNewFilePath(config, document, file);
const root = Utils.dirname(desiredPath);
const ext = Utils.extname(desiredPath);
let baseName = Utils.basename(desiredPath);
baseName = baseName.slice(0, baseName.length - ext.length);
for (let i = 0; ; ++i) {
if (token.isCancellationRequested) {
return undefined;
}
const name = i === 0 ? baseName : `${baseName}-${i}`;
const uri = vscode.Uri.joinPath(root, name + ext);
if (this._wasPathAlreadyUsed(uri)) {
continue;
}
// Try overwriting if it already exists
if (config.overwriteBehavior === 'overwrite') {
this._usedPaths.add(uri.toString());
return { uri, overwrite: true };
}
// Otherwise we need to check the fs to see if it exists
try {
await vscode.workspace.fs.stat(uri);
} catch {
if (!this._wasPathAlreadyUsed(uri)) {
// Does not exist
this._usedPaths.add(uri.toString());
return { uri, overwrite: false };
}
}
}
}
private _wasPathAlreadyUsed(uri: vscode.Uri) {
return this._usedPaths.has(uri.toString());
}
}
function getDesiredNewFilePath(config: CopyFileConfiguration, document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri {
const docUri = getParentDocumentUri(document.uri);
for (const [rawGlob, rawDest] of Object.entries(config.destination)) {
for (const glob of parseGlob(rawGlob)) {
if (picomatch.isMatch(docUri.path, glob, { dot: true })) {
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<string> {
export function parseGlob(rawGlob: string): Iterable<string> {
if (rawGlob.startsWith('/')) {
// Anchor to workspace folders
return (vscode.workspace.workspaceFolders ?? []).map(folder => vscode.Uri.joinPath(folder.uri, rawGlob).path);
@ -165,7 +98,7 @@ function resolveCopyDestinationSetting(documentUri: vscode.Uri, fileName: string
['fileExtName', path.extname(fileName).replace('.', '')], // File extension (without dot): png
]);
return outDest.replaceAll(/(?<escape>\\\$)|(?<!\\)\$\{(?<name>\w+)(?:\/(?<pattern>(?:\\\/|[^\}])+?)\/(?<replacement>(?:\\\/|[^\}])+?)\/)?\}/g, (match, _escape, name, pattern, replacement, _offset, _str, groups) => {
return outDest.replaceAll(/(?<escape>\\\$)|(?<!\\)\$\{(?<name>\w+)(?:\/(?<pattern>(?:\\\/|[^\}\/])+)\/(?<replacement>(?:\\\/|[^\}\/])*)\/)?\}/g, (match, _escape, name, pattern, replacement, _offset, _str, groups) => {
if (groups?.['escape']) {
return '$';
}
@ -176,7 +109,11 @@ function resolveCopyDestinationSetting(documentUri: vscode.Uri, fileName: string
}
if (pattern && replacement) {
return entry.replace(new RegExp(replaceTransformEscapes(pattern)), replaceTransformEscapes(replacement));
try {
return entry.replace(new RegExp(replaceTransformEscapes(pattern)), replaceTransformEscapes(replacement));
} catch (e) {
console.log(`Error applying 'resolveCopyDestinationSetting' transform: ${pattern} -> ${replacement}`);
}
}
return entry;
@ -186,4 +123,3 @@ function resolveCopyDestinationSetting(documentUri: vscode.Uri, fileName: string
function replaceTransformEscapes(str: string): string {
return str.replaceAll(/\\\//g, '/');
}

View file

@ -0,0 +1,237 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { coalesce } from '../../util/arrays';
import { getParentDocumentUri } from '../../util/document';
import { Mime, mediaMimes } from '../../util/mimes';
import { Schemes } from '../../util/schemes';
import { NewFilePathGenerator } from './newFilePathGenerator';
import { createInsertUriListEdit, createUriListSnippet, getSnippetLabel } from './shared';
/**
* Provides support for pasting or dropping resources into markdown documents.
*
* This includes:
*
* - `text/uri-list` data in the data transfer.
* - File object in the data transfer.
* - Media data in the data transfer, such as `image/png`.
*/
class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, vscode.DocumentDropEditProvider {
public static readonly id = 'insertResource';
public static readonly mimeTypes = [
Mime.textUriList,
'files',
...mediaMimes,
];
private readonly _yieldTo = [
{ mimeType: 'text/plain' },
{ extensionId: 'vscode.ipynb', providerId: 'insertAttachment' },
];
public async provideDocumentDropEdits(
document: vscode.TextDocument,
position: vscode.Position,
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<vscode.DocumentDropEdit | undefined> {
const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true);
if (!enabled) {
return;
}
const filesEdit = await this._getMediaFilesDropEdit(document, dataTransfer, token);
if (filesEdit) {
return filesEdit;
}
if (token.isCancellationRequested) {
return;
}
return this._createEditFromUriListData(document, [new vscode.Range(position, position)], dataTransfer, token);
}
public async provideDocumentPasteEdits(
document: vscode.TextDocument,
ranges: readonly vscode.Range[],
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<vscode.DocumentPasteEdit | undefined> {
const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.filePaste.enabled', true);
if (!enabled) {
return;
}
const createEdit = await this._getMediaFilesPasteEdit(document, dataTransfer, token);
if (createEdit) {
return createEdit;
}
if (token.isCancellationRequested) {
return;
}
return this._createEditFromUriListData(document, ranges, dataTransfer, token);
}
private async _createEditFromUriListData(
document: vscode.TextDocument,
ranges: readonly vscode.Range[],
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<vscode.DocumentPasteEdit | undefined> {
const uriList = await dataTransfer.get(Mime.textUriList)?.asString();
if (!uriList || token.isCancellationRequested) {
return;
}
const pasteEdit = createInsertUriListEdit(document, ranges, uriList);
if (!pasteEdit) {
return;
}
const uriEdit = new vscode.DocumentPasteEdit('', pasteEdit.label);
const edit = new vscode.WorkspaceEdit();
edit.set(document.uri, pasteEdit.edits);
uriEdit.additionalEdit = edit;
uriEdit.yieldTo = this._yieldTo;
return uriEdit;
}
private async _getMediaFilesPasteEdit(
document: vscode.TextDocument,
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<vscode.DocumentPasteEdit | undefined> {
if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) {
return;
}
const copyFilesIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.filePaste.copyIntoWorkspace', 'mediaFiles');
if (copyFilesIntoWorkspace !== 'mediaFiles') {
return;
}
const edit = await this._createEditForMediaFiles(document, dataTransfer, token);
if (!edit) {
return;
}
const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, edit.label);
pasteEdit.additionalEdit = edit.additionalEdits;
pasteEdit.yieldTo = this._yieldTo;
return pasteEdit;
}
private async _getMediaFilesDropEdit(
document: vscode.TextDocument,
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<vscode.DocumentDropEdit | undefined> {
if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) {
return;
}
const copyIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.drop.copyIntoWorkspace', 'mediaFiles');
if (copyIntoWorkspace !== 'mediaFiles') {
return;
}
const edit = await this._createEditForMediaFiles(document, dataTransfer, token);
if (!edit) {
return;
}
const dropEdit = new vscode.DocumentDropEdit(edit.snippet);
dropEdit.label = edit.label;
dropEdit.additionalEdit = edit.additionalEdits;
dropEdit.yieldTo = this._yieldTo;
return dropEdit;
}
/**
* Create a new edit for media files in a data transfer.
*
* This tries copying files outside of the workspace into the workspace.
*/
private async _createEditForMediaFiles(
document: vscode.TextDocument,
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<{ snippet: vscode.SnippetString; label: string; additionalEdits: vscode.WorkspaceEdit } | undefined> {
interface FileEntry {
readonly uri: vscode.Uri;
readonly newFile?: { readonly contents: vscode.DataTransferFile; readonly overwrite: boolean };
}
const pathGenerator = new NewFilePathGenerator();
const fileEntries = coalesce(await Promise.all(Array.from(dataTransfer, async ([mime, item]): Promise<FileEntry | undefined> => {
if (!mediaMimes.has(mime)) {
return;
}
const file = item?.asFile();
if (!file) {
return;
}
if (file.uri) {
// If the file is already in a workspace, we don't want to create a copy of it
const workspaceFolder = vscode.workspace.getWorkspaceFolder(file.uri);
if (workspaceFolder) {
return { uri: file.uri };
}
}
const newFile = await pathGenerator.getNewFilePath(document, file, token);
if (!newFile) {
return;
}
return { uri: newFile.uri, newFile: { contents: file, overwrite: newFile.overwrite } };
})));
if (!fileEntries.length) {
return;
}
const workspaceEdit = new vscode.WorkspaceEdit();
for (const entry of fileEntries) {
if (entry.newFile) {
workspaceEdit.createFile(entry.uri, {
contents: entry.newFile.contents,
overwrite: entry.newFile.overwrite,
});
}
}
const snippet = createUriListSnippet(document.uri, fileEntries);
if (!snippet) {
return;
}
return {
snippet: snippet.snippet,
label: getSnippetLabel(snippet),
additionalEdits: workspaceEdit,
};
}
}
export function registerResourceDropOrPasteSupport(selector: vscode.DocumentSelector): vscode.Disposable {
return vscode.Disposable.from(
vscode.languages.registerDocumentPasteEditProvider(selector, new ResourcePasteOrDropProvider(), {
id: ResourcePasteOrDropProvider.id,
pasteMimeTypes: ResourcePasteOrDropProvider.mimeTypes,
}),
vscode.languages.registerDocumentDropEditProvider(selector, new ResourcePasteOrDropProvider(), {
id: ResourcePasteOrDropProvider.id,
dropMimeTypes: ResourcePasteOrDropProvider.mimeTypes,
}),
);
}

View file

@ -1,86 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Mime, mediaMimes } from '../../util/mimes';
import { Schemes } from '../../util/schemes';
import { createEditForMediaFiles, tryGetUriListSnippet } from './shared';
import { getParentDocumentUri } from '../../util/document';
class ResourceDropProvider implements vscode.DocumentDropEditProvider {
public static readonly id = 'insertLink';
public static readonly dropMimeTypes = [
Mime.textUriList,
...mediaMimes,
];
private readonly _yieldTo = [
{ mimeType: 'text/plain' },
{ extensionId: 'vscode.ipynb', providerId: 'insertAttachment' },
];
async provideDocumentDropEdits(document: vscode.TextDocument, _position: vscode.Position, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentDropEdit | undefined> {
const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true);
if (!enabled) {
return;
}
const filesEdit = await this._getMediaFilesEdit(document, dataTransfer, token);
if (filesEdit) {
return filesEdit;
}
if (token.isCancellationRequested) {
return;
}
return this._getUriListEdit(document, dataTransfer, token);
}
private async _getUriListEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentDropEdit | undefined> {
const urlList = await dataTransfer.get(Mime.textUriList)?.asString();
if (!urlList || token.isCancellationRequested) {
return;
}
const snippet = tryGetUriListSnippet(document, urlList);
if (!snippet) {
return;
}
const edit = new vscode.DocumentDropEdit(snippet.snippet);
edit.label = snippet.label;
edit.yieldTo = this._yieldTo;
return edit;
}
private async _getMediaFilesEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentDropEdit | undefined> {
if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) {
return;
}
const copyIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.drop.copyIntoWorkspace', 'mediaFiles');
if (copyIntoWorkspace !== 'mediaFiles') {
return;
}
const edit = await createEditForMediaFiles(document, dataTransfer, token);
if (!edit) {
return;
}
const dropEdit = new vscode.DocumentDropEdit(edit.snippet);
dropEdit.label = edit.label;
dropEdit.additionalEdit = edit.additionalEdits;
dropEdit.yieldTo = this._yieldTo;
return dropEdit;
}
}
export function registerDropIntoEditorSupport(selector: vscode.DocumentSelector) {
return vscode.languages.registerDocumentDropEditProvider(selector, new ResourceDropProvider(), ResourceDropProvider);
}

View file

@ -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 picomatch from 'picomatch';
import * as vscode from 'vscode';
import { Utils } from 'vscode-uri';
import { getParentDocumentUri } from '../../util/document';
import { CopyFileConfiguration, getCopyFileConfiguration, parseGlob, resolveCopyDestination } from './copyFiles';
export class NewFilePathGenerator {
private readonly _usedPaths = new Set<string>();
async getNewFilePath(
document: vscode.TextDocument,
file: vscode.DataTransferFile,
token: vscode.CancellationToken
): Promise<{ readonly uri: vscode.Uri; readonly overwrite: boolean } | undefined> {
const config = getCopyFileConfiguration(document);
const desiredPath = getDesiredNewFilePath(config, document, file);
const root = Utils.dirname(desiredPath);
const ext = Utils.extname(desiredPath);
let baseName = Utils.basename(desiredPath);
baseName = baseName.slice(0, baseName.length - ext.length);
for (let i = 0; ; ++i) {
if (token.isCancellationRequested) {
return undefined;
}
const name = i === 0 ? baseName : `${baseName}-${i}`;
const uri = vscode.Uri.joinPath(root, name + ext);
if (this._wasPathAlreadyUsed(uri)) {
continue;
}
// Try overwriting if it already exists
if (config.overwriteBehavior === 'overwrite') {
this._usedPaths.add(uri.toString());
return { uri, overwrite: true };
}
// Otherwise we need to check the fs to see if it exists
try {
await vscode.workspace.fs.stat(uri);
} catch {
if (!this._wasPathAlreadyUsed(uri)) {
// Does not exist
this._usedPaths.add(uri.toString());
return { uri, overwrite: false };
}
}
}
}
private _wasPathAlreadyUsed(uri: vscode.Uri) {
return this._usedPaths.has(uri.toString());
}
}
export function getDesiredNewFilePath(config: CopyFileConfiguration, document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri {
const docUri = getParentDocumentUri(document.uri);
for (const [rawGlob, rawDest] of Object.entries(config.destination)) {
for (const glob of parseGlob(rawGlob)) {
if (picomatch.isMatch(docUri.path, glob, { dot: true })) {
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);
}

View file

@ -1,91 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { Mime, mediaMimes } from '../../util/mimes';
import { Schemes } from '../../util/schemes';
import { PasteUrlAsFormattedLink, createEditAddingLinksForUriList, createEditForMediaFiles, getPasteUrlAsFormattedLinkSetting } from './shared';
import { getParentDocumentUri } from '../../util/document';
class PasteResourceEditProvider implements vscode.DocumentPasteEditProvider {
public static readonly id = 'insertLink';
public static readonly pasteMimeTypes = [
Mime.textUriList,
...mediaMimes,
];
private readonly _yieldTo = [
{ mimeType: 'text/plain' },
{ extensionId: 'vscode.ipynb', providerId: 'insertAttachment' },
];
async provideDocumentPasteEdits(
document: vscode.TextDocument,
ranges: readonly vscode.Range[],
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<vscode.DocumentPasteEdit | undefined> {
const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.filePaste.enabled', true);
if (!enabled) {
return;
}
const createEdit = await this._getMediaFilesEdit(document, dataTransfer, token);
if (createEdit) {
return createEdit;
}
if (token.isCancellationRequested) {
return;
}
return this._getUriListEdit(document, ranges, dataTransfer, token);
}
private async _getUriListEdit(document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentPasteEdit | undefined> {
const uriList = await dataTransfer.get(Mime.textUriList)?.asString();
if (!uriList || token.isCancellationRequested) {
return;
}
const pasteUrlSetting = getPasteUrlAsFormattedLinkSetting(document);
const pasteEdit = createEditAddingLinksForUriList(document, ranges, uriList, false, pasteUrlSetting === PasteUrlAsFormattedLink.Smart);
if (!pasteEdit) {
return;
}
const uriEdit = new vscode.DocumentPasteEdit('', pasteEdit.label);
uriEdit.additionalEdit = pasteEdit.additionalEdits;
uriEdit.yieldTo = this._yieldTo;
return uriEdit;
}
private async _getMediaFilesEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentPasteEdit | undefined> {
if (getParentDocumentUri(document.uri).scheme === Schemes.untitled) {
return;
}
const copyFilesIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.filePaste.copyIntoWorkspace', 'mediaFiles');
if (copyFilesIntoWorkspace === 'never') {
return;
}
const edit = await createEditForMediaFiles(document, dataTransfer, token);
if (!edit) {
return;
}
const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, edit.label);
pasteEdit.additionalEdit = edit.additionalEdits;
pasteEdit.yieldTo = this._yieldTo;
return pasteEdit;
}
}
export function registerPasteSupport(selector: vscode.DocumentSelector,) {
return vscode.languages.registerDocumentPasteEditProvider(selector, new PasteResourceEditProvider(), PasteResourceEditProvider);
}

View file

@ -4,16 +4,31 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { ITextDocument } from '../../types/textDocument';
import { Mime } from '../../util/mimes';
import { createEditAddingLinksForUriList, findValidUriInText, getPasteUrlAsFormattedLinkSetting, PasteUrlAsFormattedLink } from './shared';
import { createInsertUriListEdit, externalUriSchemes } from './shared';
enum PasteUrlAsFormattedLink {
Always = 'always',
Smart = 'smart',
Never = 'never'
}
function getPasteUrlAsFormattedLinkSetting(document: vscode.TextDocument): PasteUrlAsFormattedLink {
return vscode.workspace.getConfiguration('markdown', document)
.get<PasteUrlAsFormattedLink>('editor.pasteUrlAsFormattedLink.enabled', PasteUrlAsFormattedLink.Smart);
}
/**
* Adds support for pasting text uris to create markdown links.
*
* This only applies to `text/plain`. Other mimes like `text/uri-list` are handled by ResourcePasteOrDropProvider.
*/
class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider {
public static readonly id = 'insertMarkdownLink';
public static readonly pasteMimeTypes = [
Mime.textPlain,
];
public static readonly pasteMimeTypes = [Mime.textPlain];
async provideDocumentPasteEdits(
document: vscode.TextDocument,
@ -37,18 +52,99 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider {
return;
}
const pasteEdit = createEditAddingLinksForUriList(document, ranges, uriText, true, pasteUrlSetting === PasteUrlAsFormattedLink.Smart);
if (!pasteEdit) {
const edit = createInsertUriListEdit(document, ranges, uriText);
if (!edit) {
return;
}
const edit = new vscode.DocumentPasteEdit('', pasteEdit.label);
edit.additionalEdit = pasteEdit.additionalEdits;
edit.yieldTo = pasteEdit.markdownLink ? undefined : [{ mimeType: Mime.textPlain }];
return edit;
const pasteEdit = new vscode.DocumentPasteEdit('', edit.label);
const workspaceEdit = new vscode.WorkspaceEdit();
workspaceEdit.set(document.uri, edit.edits);
pasteEdit.additionalEdit = workspaceEdit;
// If smart pasting is enabled, deprioritize this provider when:
// - The user has no selection
// - At least one of the ranges occurs in a context where smart pasting is disabled (such as a fenced code block)
if (pasteUrlSetting === PasteUrlAsFormattedLink.Smart) {
if (!ranges.every(range => shouldSmartPaste(document, range))) {
pasteEdit.yieldTo = [{ mimeType: Mime.textPlain }];
}
}
return pasteEdit;
}
}
export function registerLinkPasteSupport(selector: vscode.DocumentSelector,) {
return vscode.languages.registerDocumentPasteEditProvider(selector, new PasteUrlEditProvider(), PasteUrlEditProvider);
return vscode.languages.registerDocumentPasteEditProvider(selector, new PasteUrlEditProvider(), {
id: PasteUrlEditProvider.id,
pasteMimeTypes: PasteUrlEditProvider.pasteMimeTypes,
});
}
const smartPasteRegexes = [
{ regex: /(\[[^\[\]]*](?:\([^\(\)]*\)|\[[^\[\]]*]))/g }, // In a Markdown link
{ regex: /^```[\s\S]*?```$/gm }, // In a backtick fenced code block
{ regex: /^~~~[\s\S]*?~~~$/gm }, // In a tildefenced code block
{ regex: /^\$\$[\s\S]*?\$\$$/gm }, // In a fenced math block
{ regex: /`[^`]*`/g }, // In inline code
{ regex: /\$[^$]*\$/g }, // In inline math
];
export function shouldSmartPaste(document: ITextDocument, selectedRange: vscode.Range): boolean {
// Disable for empty selections and multi-line selections
if (selectedRange.isEmpty || selectedRange.start.line !== selectedRange.end.line) {
return false;
}
const rangeText = document.getText(selectedRange);
// Disable for whitespace only selections
if (rangeText.trim().length === 0) {
return false;
}
// Disable when the selection is already a link
if (findValidUriInText(rangeText)) {
return false;
}
if (/\[.*\]\(.*\)/.test(rangeText) || /!\[.*\]\(.*\)/.test(rangeText)) {
return false;
}
for (const regex of smartPasteRegexes) {
const matches = [...document.getText().matchAll(regex.regex)];
for (const match of matches) {
if (match.index !== undefined) {
const useDefaultPaste = selectedRange.start.character > match.index && selectedRange.end.character < match.index + match[0].length;
if (useDefaultPaste) {
return false;
}
}
}
}
return true;
}
export function findValidUriInText(text: string): string | undefined {
const trimmedUrlList = text.trim();
// Uri must consist of a single sequence of characters without spaces
if (!/^\S+$/.test(trimmedUrlList)) {
return;
}
let uri: vscode.Uri;
try {
uri = vscode.Uri.parse(trimmedUrlList);
} catch {
// Could not parse
return;
}
if (!externalUriSchemes.includes(uri.scheme.toLowerCase()) || uri.authority.length <= 1) {
return;
}
return trimmedUrlList;
}

View file

@ -9,9 +9,9 @@ import * as URI from 'vscode-uri';
import { ITextDocument } from '../../types/textDocument';
import { coalesce } from '../../util/arrays';
import { getDocumentDir } from '../../util/document';
import { mediaMimes } from '../../util/mimes';
import { Schemes } from '../../util/schemes';
import { NewFilePathGenerator } from './copyFiles';
import { resolveSnippet } from './snippets';
import { parseUriList } from '../../util/uriList';
enum MediaKind {
Image,
@ -19,7 +19,7 @@ enum MediaKind {
Audio,
}
const externalUriSchemes = [
export const externalUriSchemes = [
'http',
'https',
'mailto',
@ -51,114 +51,36 @@ export const mediaFileExtensions = new Map<string, MediaKind>([
['wav', MediaKind.Audio],
]);
export enum PasteUrlAsFormattedLink {
Always = 'always',
Smart = 'smart',
Never = 'never'
export function getSnippetLabel(counter: { insertedAudioVideoCount: number; insertedImageCount: number; insertedLinkCount: number }) {
if (counter.insertedAudioVideoCount > 0) {
if (counter.insertedLinkCount > 0) {
return vscode.l10n.t('Insert Markdown Media and Links');
} else {
return vscode.l10n.t('Insert Markdown Media');
}
} else if (counter.insertedImageCount > 0 && counter.insertedLinkCount > 0) {
return vscode.l10n.t('Insert Markdown Images and Links');
} else if (counter.insertedImageCount > 0) {
return counter.insertedImageCount > 1
? vscode.l10n.t('Insert Markdown Images')
: vscode.l10n.t('Insert Markdown Image');
} else {
return counter.insertedLinkCount > 1
? vscode.l10n.t('Insert Markdown Links')
: vscode.l10n.t('Insert Markdown Link');
}
}
export function getPasteUrlAsFormattedLinkSetting(document: vscode.TextDocument): PasteUrlAsFormattedLink {
return vscode.workspace.getConfiguration('markdown', document).get<PasteUrlAsFormattedLink>('editor.pasteUrlAsFormattedLink.enabled', PasteUrlAsFormattedLink.Smart);
}
export function createEditAddingLinksForUriList(
export function createInsertUriListEdit(
document: ITextDocument,
ranges: readonly vscode.Range[],
urlList: string,
isExternalLink: boolean,
useSmartPaste: boolean,
): { additionalEdits: vscode.WorkspaceEdit; label: string; markdownLink: boolean } | undefined {
): { edits: vscode.SnippetTextEdit[]; label: string } | undefined {
if (!ranges.length) {
return;
}
const edits: vscode.SnippetTextEdit[] = [];
let placeHolderValue: number = ranges.length;
let label: string = '';
let pasteAsMarkdownLink: boolean = true;
let markdownLink: boolean = true;
for (const range of ranges) {
if (useSmartPaste) {
pasteAsMarkdownLink = shouldSmartPaste(document, range);
markdownLink = pasteAsMarkdownLink; // FIX: this will only match the last range
}
const snippet = tryGetUriListSnippet(document, urlList, document.getText(range), placeHolderValue, pasteAsMarkdownLink, isExternalLink);
if (!snippet) {
return;
}
pasteAsMarkdownLink = true;
placeHolderValue--;
edits.push(new vscode.SnippetTextEdit(range, snippet.snippet));
label = snippet.label;
}
const additionalEdits = new vscode.WorkspaceEdit();
additionalEdits.set(document.uri, edits);
return { additionalEdits, label, markdownLink };
}
export function findValidUriInText(text: string): string | undefined {
const trimmedUrlList = text.trim();
// Uri must consist of a single sequence of characters without spaces
if (!/^\S+$/.test(trimmedUrlList)) {
return;
}
let uri: vscode.Uri;
try {
uri = vscode.Uri.parse(trimmedUrlList);
} catch {
// Could not parse
return;
}
if (!externalUriSchemes.includes(uri.scheme.toLowerCase()) || uri.authority.length <= 1) {
return;
}
return trimmedUrlList;
}
const smartPasteRegexes = [
{ regex: /(\[[^\[\]]*](?:\([^\(\)]*\)|\[[^\[\]]*]))/g }, // In a Markdown link
{ regex: /^```[\s\S]*?```$/gm }, // In a backtick fenced code block
{ regex: /^~~~[\s\S]*?~~~$/gm }, // In a tildefenced code block
{ regex: /^\$\$[\s\S]*?\$\$$/gm }, // In a fenced math block
{ regex: /`[^`]*`/g }, // In inline code
{ regex: /\$[^$]*\$/g }, // In inline math
];
export function shouldSmartPaste(document: ITextDocument, selectedRange: vscode.Range): boolean {
if (selectedRange.isEmpty || /^[\s\n]*$/.test(document.getText(selectedRange)) || findValidUriInText(document.getText(selectedRange))) {
return false;
}
if (/\[.*\]\(.*\)/.test(document.getText(selectedRange)) || /!\[.*\]\(.*\)/.test(document.getText(selectedRange))) {
return false;
}
for (const regex of smartPasteRegexes) {
const matches = [...document.getText().matchAll(regex.regex)];
for (const match of matches) {
if (match.index !== undefined) {
const useDefaultPaste = selectedRange.start.character > match.index && selectedRange.end.character < match.index + match[0].length;
if (useDefaultPaste) {
return false;
}
}
}
}
return true;
}
export function tryGetUriListSnippet(document: ITextDocument, urlList: String, title = '', placeHolderValue = 0, pasteAsMarkdownLink = true, isExternalLink = false): { snippet: vscode.SnippetString; label: string } | undefined {
const entries = coalesce(urlList.split(/\r?\n/g).map(line => {
const entries = coalesce(parseUriList(urlList).map(line => {
try {
return { uri: vscode.Uri.parse(line), str: line };
} catch {
@ -166,7 +88,36 @@ export function tryGetUriListSnippet(document: ITextDocument, urlList: String, t
return undefined;
}
}));
return createUriListSnippet(document, entries, title, placeHolderValue, pasteAsMarkdownLink, isExternalLink);
if (!entries.length) {
return;
}
const edits: vscode.SnippetTextEdit[] = [];
let placeHolderValue = ranges.length;
let insertedLinkCount = 0;
let insertedImageCount = 0;
let insertedAudioVideoCount = 0;
for (const range of ranges) {
const snippet = createUriListSnippet(document.uri, entries, {
placeholderText: !range.isEmpty ? document.getText(range) : undefined,
placeholderStartIndex: placeHolderValue,
});
if (!snippet) {
continue;
}
insertedLinkCount += snippet.insertedLinkCount;
insertedImageCount += snippet.insertedImageCount;
insertedAudioVideoCount += snippet.insertedAudioVideoCount;
placeHolderValue--;
edits.push(new vscode.SnippetTextEdit(range, snippet.snippet));
}
const label = getSnippetLabel({ insertedAudioVideoCount, insertedImageCount, insertedLinkCount });
return { edits, label };
}
interface UriListSnippetOptions {
@ -175,82 +126,76 @@ interface UriListSnippetOptions {
readonly placeholderStartIndex?: number;
/**
* Should the snippet be for an image link or video?
* Controls if a media link (`![](...)`) is inserted instead of a normal markdown link.
*
* If `undefined`, tries to infer this from the uri.
* By default tries to infer this from the uri.
*/
readonly insertAsMedia?: boolean;
readonly separator?: string;
}
export function appendToLinkSnippet(
snippet: vscode.SnippetString,
title: string,
link: string,
placeholderValue: number,
_isExternalLink: boolean,
): void {
snippet.appendText('[');
snippet.appendPlaceholder(escapeBrackets(title) || 'Title', placeholderValue);
snippet.appendText(`](${escapeMarkdownLinkPath(link)})`);
interface UriSnippet {
snippet: vscode.SnippetString;
insertedLinkCount: number;
insertedImageCount: number;
insertedAudioVideoCount: number;
}
export function createUriListSnippet(
document: ITextDocument,
document: vscode.Uri,
uris: ReadonlyArray<{
readonly uri: vscode.Uri;
readonly str?: string;
}>,
title = '',
placeholderValue = 0,
pasteAsMarkdownLink = true,
isExternalLink = false,
options?: UriListSnippetOptions,
): { snippet: vscode.SnippetString; label: string } | undefined {
): UriSnippet | undefined {
if (!uris.length) {
return;
}
const documentDir = getDocumentDir(document.uri);
const documentDir = getDocumentDir(document);
const config = vscode.workspace.getConfiguration('markdown', document);
const title = options?.placeholderText || 'Title';
const snippet = new vscode.SnippetString();
let insertedLinkCount = 0;
let insertedImageCount = 0;
let insertedAudioVideoCount = 0;
const snippet = new vscode.SnippetString();
let placeholderIndex = options?.placeholderStartIndex ?? 1;
uris.forEach((uri, i) => {
const mdPath = getRelativeMdPath(documentDir, uri.uri) ?? uri.str ?? uri.uri.toString();
const ext = URI.Utils.extname(uri.uri).toLowerCase().replace('.', '');
const insertAsMedia = typeof options?.insertAsMedia === 'undefined' ? mediaFileExtensions.has(ext) : !!options.insertAsMedia;
const insertAsVideo = mediaFileExtensions.get(ext) === MediaKind.Video;
const insertAsAudio = mediaFileExtensions.get(ext) === MediaKind.Audio;
const insertAsMedia = options?.insertAsMedia || (typeof options?.insertAsMedia === 'undefined' && mediaFileExtensions.has(ext));
if (insertAsVideo) {
insertedAudioVideoCount++;
snippet.appendText(`<video src="${escapeHtmlAttribute(mdPath)}" controls title="`);
snippet.appendPlaceholder(escapeBrackets(title) || 'Title', placeholderValue);
snippet.appendText('"></video>');
} else if (insertAsAudio) {
insertedAudioVideoCount++;
snippet.appendText(`<audio src="${escapeHtmlAttribute(mdPath)}" controls title="`);
snippet.appendPlaceholder(escapeBrackets(title) || 'Title', placeholderValue);
snippet.appendText('"></audio>');
} else if (insertAsMedia) {
insertedImageCount++;
if (pasteAsMarkdownLink) {
if (insertAsMedia) {
const insertAsVideo = mediaFileExtensions.get(ext) === MediaKind.Video;
const insertAsAudio = mediaFileExtensions.get(ext) === MediaKind.Audio;
if (insertAsVideo || insertAsAudio) {
insertedAudioVideoCount++;
const mediaSnippet = insertAsVideo
? config.get<string>('editor.filePaste.videoSnippet', '<video controls src="${src}" title="${title}"></video>')
: config.get<string>('editor.filePaste.audioSnippet', '<audio controls src="${src}" title="${title}"></audio>');
snippet.value += resolveSnippet(mediaSnippet, new Map<string, string>([
['src', mdPath],
['title', `\${${placeholderIndex++}:${title}}`],
]));
} else {
insertedImageCount++;
snippet.appendText('![');
const placeholderText = escapeBrackets(title) || options?.placeholderText || 'Alt text';
const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : (placeholderValue === 0 ? undefined : placeholderValue);
const placeholderText = escapeBrackets(options?.placeholderText || 'alt text');
snippet.appendPlaceholder(placeholderText, placeholderIndex);
snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);
} else {
snippet.appendText(escapeMarkdownLinkPath(mdPath));
}
} else {
insertedLinkCount++;
appendToLinkSnippet(snippet, title, mdPath, placeholderValue, isExternalLink);
snippet.appendText('[');
snippet.appendPlaceholder(escapeBrackets(options?.placeholderText ?? 'text'), placeholderIndex);
snippet.appendText(`](${escapeMarkdownLinkPath(mdPath)})`);
}
if (i < uris.length - 1 && uris.length > 1) {
@ -258,97 +203,9 @@ export function createUriListSnippet(
}
});
let label: string;
if (insertedAudioVideoCount > 0) {
if (insertedLinkCount > 0) {
label = vscode.l10n.t('Insert Markdown Media and Links');
} else {
label = vscode.l10n.t('Insert Markdown Media');
}
} else if (insertedImageCount > 0 && insertedLinkCount > 0) {
label = vscode.l10n.t('Insert Markdown Images and Links');
} else if (insertedImageCount > 0) {
label = insertedImageCount > 1
? vscode.l10n.t('Insert Markdown Images')
: vscode.l10n.t('Insert Markdown Image');
} else {
label = insertedLinkCount > 1
? vscode.l10n.t('Insert Markdown Links')
: vscode.l10n.t('Insert Markdown Link');
}
return { snippet, label };
return { snippet, insertedAudioVideoCount, insertedImageCount, insertedLinkCount };
}
/**
* Create a new edit from the image files in a data transfer.
*
* This tries copying files outside of the workspace into the workspace.
*/
export async function createEditForMediaFiles(
document: vscode.TextDocument,
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken
): Promise<{ snippet: vscode.SnippetString; label: string; additionalEdits: vscode.WorkspaceEdit } | undefined> {
if (document.uri.scheme === Schemes.untitled) {
return;
}
interface FileEntry {
readonly uri: vscode.Uri;
readonly newFile?: { readonly contents: vscode.DataTransferFile; readonly overwrite: boolean };
}
const pathGenerator = new NewFilePathGenerator();
const fileEntries = coalesce(await Promise.all(Array.from(dataTransfer, async ([mime, item]): Promise<FileEntry | undefined> => {
if (!mediaMimes.has(mime)) {
return;
}
const file = item?.asFile();
if (!file) {
return;
}
if (file.uri) {
// If the file is already in a workspace, we don't want to create a copy of it
const workspaceFolder = vscode.workspace.getWorkspaceFolder(file.uri);
if (workspaceFolder) {
return { uri: file.uri };
}
}
const newFile = await pathGenerator.getNewFilePath(document, file, token);
if (!newFile) {
return;
}
return { uri: newFile.uri, newFile: { contents: file, overwrite: newFile.overwrite } };
})));
if (!fileEntries.length) {
return;
}
const workspaceEdit = new vscode.WorkspaceEdit();
for (const entry of fileEntries) {
if (entry.newFile) {
workspaceEdit.createFile(entry.uri, {
contents: entry.newFile.contents,
overwrite: entry.newFile.overwrite,
});
}
}
const snippet = createUriListSnippet(document, fileEntries);
if (!snippet) {
return;
}
return {
snippet: snippet.snippet,
label: snippet.label,
additionalEdits: workspaceEdit,
};
}
function getRelativeMdPath(dir: vscode.Uri | undefined, file: vscode.Uri): string | undefined {
if (dir && dir.scheme === file.scheme && dir.authority === file.authority) {
@ -365,10 +222,6 @@ function getRelativeMdPath(dir: vscode.Uri | undefined, file: vscode.Uri): strin
return undefined;
}
function escapeHtmlAttribute(attr: string): string {
return encodeURI(attr).replaceAll('"', '&quot;');
}
function escapeMarkdownLinkPath(mdPath: string): string {
if (needsBracketLink(mdPath)) {
return '<' + mdPath.replaceAll('<', '\\<').replaceAll('>', '\\>') + '>';

View file

@ -0,0 +1,32 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Resolves variables in a VS Code snippet style string
*/
export function resolveSnippet(snippetString: string, vars: ReadonlyMap<string, string>): string {
return snippetString.replaceAll(/(?<escape>\\\$)|(?<!\\)\$\{(?<name>\w+)(?:\/(?<pattern>(?:\\\/|[^\}])+?)\/(?<replacement>(?:\\\/|[^\}])+?)\/)?\}/g, (match, _escape, name, pattern, replacement, _offset, _str, groups) => {
if (groups?.['escape']) {
return '$';
}
const entry = vars.get(name);
if (typeof entry !== 'string') {
return match;
}
if (pattern && replacement) {
return entry.replace(new RegExp(replaceTransformEscapes(pattern)), replaceTransformEscapes(replacement));
}
return entry;
});
}
function replaceTransformEscapes(str: string): string {
return str.replaceAll(/\\\//g, '/');
}

View file

@ -6,15 +6,20 @@ import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import { InMemoryDocument } from '../client/inMemoryDocument';
import { appendToLinkSnippet, createEditAddingLinksForUriList, findValidUriInText, shouldSmartPaste } from '../languageFeatures/copyFiles/shared';
import { findValidUriInText, shouldSmartPaste } from '../languageFeatures/copyFiles/pasteUrlProvider';
import { createInsertUriListEdit } from '../languageFeatures/copyFiles/shared';
function makeTestDoc(contents: string) {
return new InMemoryDocument(vscode.Uri.file('test.md'), contents);
}
suite('createEditAddingLinksForUriList', () => {
test('Markdown Link Pasting should occur for a valid link (end to end)', async () => {
// createEditAddingLinksForUriList -> checkSmartPaste -> tryGetUriListSnippet -> createUriListSnippet -> createLinkSnippet
const result = createEditAddingLinksForUriList(
new InMemoryDocument(vscode.Uri.file('test.md'), 'hello world!'), [new vscode.Range(0, 0, 0, 12)], 'https://www.microsoft.com/', true, true);
const result = createInsertUriListEdit(
new InMemoryDocument(vscode.Uri.file('test.md'), 'hello world!'), [new vscode.Range(0, 0, 0, 12)], 'https://www.microsoft.com/');
// need to check the actual result -> snippet value
assert.strictEqual(result?.label, 'Insert Markdown Link');
});
@ -100,41 +105,31 @@ suite('createEditAddingLinksForUriList', () => {
});
});
suite('appendToLinkSnippet', () => {
suite('createInsertUriListEdit', () => {
test('Should create snippet with < > when pasted link has an mismatched parentheses', () => {
const uriString = 'https://www.mic(rosoft.com';
const snippet = new vscode.SnippetString('');
appendToLinkSnippet(snippet, 'abc', uriString, 0, true);
assert.strictEqual(snippet?.value, '[${0:abc}](<https://www.mic(rosoft.com>)');
const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.mic(rosoft.com');
assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](<https://www.mic(rosoft.com>)');
});
test('Should create Markdown link snippet when pasteAsMarkdownLink is true', () => {
const uriString = 'https://www.microsoft.com';
const snippet = new vscode.SnippetString('');
appendToLinkSnippet(snippet, '', uriString, 0, true);
assert.strictEqual(snippet?.value, '[${0:Title}](https://www.microsoft.com)');
const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com');
assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com)');
});
test('Should use an unencoded URI string in Markdown link when passing in an external browser link', () => {
const uriString = 'https://www.microsoft.com';
const snippet = new vscode.SnippetString('');
appendToLinkSnippet(snippet, '', uriString, 0, true);
assert.strictEqual(snippet?.value, '[${0:Title}](https://www.microsoft.com)');
const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com');
assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com)');
});
test('Should not decode an encoded URI string when passing in an external browser link', () => {
const uriString = 'https://www.microsoft.com/%20';
const snippet = new vscode.SnippetString('');
appendToLinkSnippet(snippet, '', uriString, 0, true);
assert.strictEqual(snippet?.value, '[${0:Title}](https://www.microsoft.com/%20)');
const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.microsoft.com/%20');
assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.microsoft.com/%20)');
});
test('Should not encode an unencoded URI string when passing in an external browser link', () => {
const uriString = 'https://www.example.com/path?query=value&another=value#fragment';
const snippet = new vscode.SnippetString('');
appendToLinkSnippet(snippet, '', uriString, 0, true);
assert.strictEqual(snippet?.value, '[${0:Title}](https://www.example.com/path?query=value&another=value#fragment)');
const edit = createInsertUriListEdit(makeTestDoc(''), [new vscode.Range(0, 0, 0, 0)], 'https://www.example.com/path?query=value&another=value#fragment');
assert.strictEqual(edit?.edits?.[0].snippet.value, '[${1:text}](https://www.example.com/path?query=value&another=value#fragment)');
});
});
@ -220,7 +215,3 @@ suite('createEditAddingLinksForUriList', () => {
});
});
});
function makeTestDoc(contents: string) {
return new InMemoryDocument(vscode.Uri.file('test.md'), contents);
}

View file

@ -0,0 +1,14 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
function splitUriList(str: string): string[] {
return str.split('\r\n');
}
export function parseUriList(str: string): string[] {
return splitUriList(str)
.filter(value => !value.startsWith('#')) // Remove comments
.map(value => value.trim());
}