Allow external copying files into the workspace on markdown drop / paste (#182572)

Allow copying files in the workspace on markdown drop / paste

Fixes #157043

Also:

- Renames the markdown paste settings and makes them no longer experimental
- Makes the copyFiles setting no longer experimental
- Adds a `markdown.copyFiles.overwriteBehavior` which lets you control if/how existing files are overwritten
This commit is contained in:
Matt Bierner 2023-05-15 20:17:52 -07:00 committed by GitHub
parent 97f8af3e08
commit 7a7d45793b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 435 additions and 252 deletions

View file

@ -456,13 +456,36 @@
"markdownDescription": "%configuration.markdown.editor.drop.enabled%",
"scope": "resource"
},
"markdown.experimental.editor.pasteLinks.enabled": {
"markdown.editor.drop.copyIntoWorkspace": {
"type": "string",
"markdownDescription": "%configuration.markdown.editor.drop.copyIntoWorkspace%",
"default": "mediaFiles",
"enum": [
"mediaFiles",
"never"
],
"markdownEnumDescriptions": [
"%configuration.copyIntoWorkspace.mediaFiles%",
"%configuration.copyIntoWorkspace.never%"
]
},
"markdown.editor.filePaste.enabled": {
"type": "boolean",
"scope": "resource",
"markdownDescription": "%configuration.markdown.editor.pasteLinks.enabled%",
"default": true,
"tags": [
"experimental"
"markdownDescription": "%configuration.markdown.editor.filePaste.enabled%",
"default": true
},
"markdown.editor.filePaste.copyIntoWorkspace": {
"type": "string",
"markdownDescription": "%configuration.markdown.editor.filePaste.copyIntoWorkspace%",
"default": "mediaFiles",
"enum": [
"mediaFiles",
"never"
],
"markdownEnumDescriptions": [
"%configuration.copyIntoWorkspace.mediaFiles%",
"%configuration.copyIntoWorkspace.never%"
]
},
"markdown.validate.enabled": {
@ -588,13 +611,26 @@
"description": "%configuration.markdown.occurrencesHighlight.enabled%",
"scope": "resource"
},
"markdown.experimental.copyFiles.destination": {
"markdown.copyFiles.destination": {
"type": "object",
"markdownDescription": "%configuration.markdown.copyFiles.destination%",
"additionalProperties": {
"type": "string"
}
},
"markdown.copyFiles.overwriteBehavior": {
"type": "string",
"markdownDescription": "%configuration.markdown.copyFiles.overwriteBehavior%",
"default": "nameIncrementally",
"enum": [
"nameIncrementally",
"overwrite"
],
"markdownEnumDescriptions": [
"%configuration.markdown.copyFiles.overwriteBehavior.nameIncrementally%",
"%configuration.markdown.copyFiles.overwriteBehavior.overwrite%"
]
},
"markdown.preferredMdPathExtensionStyle": {
"type": "string",
"default": "auto",

View file

@ -32,17 +32,21 @@
"configuration.markdown.links.openLocation.currentGroup": "Open links in the active editor group.",
"configuration.markdown.links.openLocation.beside": "Open links beside the active editor.",
"configuration.markdown.suggest.paths.enabled.description": "Enable path suggestions while writing links in Markdown files.",
"configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions": "Enable suggestions for headers in other Markdown files in the current workspace. Accepting one of these suggestions inserts the full path to header in that file, for example `[link text](/path/to/file.md#header)`.",
"configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions": "Enable suggestions for headers in other Markdown files in the current workspace. Accepting one of these suggestions inserts the full path to header in that file, for example: `[link text](/path/to/file.md#header)`.",
"configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.never": "Disable workspace header suggestions.",
"configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onDoubleHash": "Enable workspace header suggestions after typing `##` in a path, for example `[link text](##`.",
"configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onSingleOrDoubleHash": "Enable workspace header suggestions after typing either `##` or `#` in a path, for example `[link text](#` or `[link text](##`.",
"configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onDoubleHash": "Enable workspace header suggestions after typing `##` in a path, for example: `[link text](##`.",
"configuration.markdown.suggest.paths.includeWorkspaceHeaderCompletions.onSingleOrDoubleHash": "Enable workspace header suggestions after typing either `##` or `#` in a path, for example: `[link text](#` or `[link text](##`.",
"configuration.markdown.editor.drop.enabled": "Enable dropping files into a Markdown editor while holding Shift. Requires enabling `#editor.dropIntoEditor.enabled#`.",
"configuration.markdown.editor.pasteLinks.enabled": "Enable pasting files into a Markdown editor inserts Markdown links. Requires enabling `#editor.pasteAs.enabled#`.",
"configuration.markdown.editor.drop.copyIntoWorkspace": "Controls if files outside of the workspace that are dropped into a Markdown editor should be copied into the workspace.\n\nUse `#markdown.copyFiles.destination#` to configure where copied dropped files should be created",
"configuration.markdown.editor.filePaste.enabled": "Enable pasting files into a Markdown editor to create Markdown links. Requires enabling `#editor.pasteAs.enabled#`.",
"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.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#`.",
"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#`.",
"configuration.markdown.validate.fileLinks.enabled.description": "Validate links to other files in Markdown files, for example `[link](/path/to/file.md)`. This checks that the target files exists. Requires enabling `#markdown.validate.enabled#`.",
"configuration.markdown.validate.fileLinks.markdownFragmentLinks.description": "Validate the fragment part of links to headers in other files in Markdown files, for example `[link](/path/to/file.md#header)`. Inherits the setting value from `#markdown.validate.fragmentLinks.enabled#` by default.",
"configuration.markdown.validate.fileLinks.markdownFragmentLinks.description": "Validate the fragment part of links to headers in other files in Markdown files, for example: `[link](/path/to/file.md#header)`. Inherits the setting value from `#markdown.validate.fragmentLinks.enabled#` by default.",
"configuration.markdown.validate.ignoredLinks.description": "Configure links that should not be validated. For example adding `/about` would not validate the link `[about](/about)`, while the glob `/assets/**/*.svg` would let you skip validation for any link to `.svg` files under the `assets` directory.",
"configuration.markdown.validate.unusedLinkDefinitions.description": "Validate link definitions that are unused in the current file.",
"configuration.markdown.validate.duplicateLinkDefinitions.description": "Validate duplicated definitions in the current file.",
@ -54,7 +58,10 @@
"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`.",
"configuration.markdown.copyFiles.destination": "Defines where files copied created by drop or paste 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 example: `/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`.",
"configuration.markdown.copyFiles.overwriteBehavior": "Controls if files created by drop or paste should overwrite existing files.",
"configuration.markdown.copyFiles.overwriteBehavior.nameIncrementally": "If a file with the same name already exists, append a number to the file name, for example: `image.png` becomes `image-1.png`.",
"configuration.markdown.copyFiles.overwriteBehavior.overwrite": "If a file with the same name already exists, overwrite it.",
"configuration.markdown.preferredMdPathExtensionStyle": "Controls if file extensions (e.g. `.md`) are added or not for links to Markdown files. This setting is used when file paths are added by tooling such as path completions or file renames.",
"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`.",

View file

@ -6,8 +6,9 @@
import * as vscode from 'vscode';
import { Utils } from 'vscode-uri';
import { Command } from '../commandManager';
import { createUriListSnippet, getParentDocumentUri, imageFileExtensions } from '../languageFeatures/copyFiles/dropIntoEditor';
import { createUriListSnippet, mediaFileExtensions } from '../languageFeatures/copyFiles/shared';
import { coalesce } from '../util/arrays';
import { getParentDocumentUri } from '../util/document';
import { Schemes } from '../util/schemes';
@ -47,7 +48,7 @@ export class InsertImageFromWorkspace implements Command {
canSelectFolders: false,
canSelectMany: true,
filters: {
[vscode.l10n.t("Images")]: Array.from(imageFileExtensions)
[vscode.l10n.t("Media")]: Array.from(mediaFileExtensions.keys())
},
openLabel: vscode.l10n.t("Insert image"),
title: vscode.l10n.t("Insert image"),
@ -75,14 +76,14 @@ async function insertLink(activeEditor: vscode.TextEditor, selectedFiles: vscode
await vscode.workspace.applyEdit(edit);
}
function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: vscode.Uri[], insertAsImage: boolean) {
function createInsertLinkEdit(activeEditor: vscode.TextEditor, selectedFiles: 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, {
insertAsImage: insertAsImage,
insertAsMedia,
placeholderText: selectionText,
placeholderStartIndex: (i + 1) * selectedFiles.length,
separator: insertAsImage ? '\n' : ' ',
separator: insertAsMedia ? '\n' : ' ',
});
return snippet ? new vscode.SnippetTextEdit(selection, snippet.snippet) : undefined;

View file

@ -6,14 +6,41 @@ import * as path from 'path';
import * as picomatch from 'picomatch';
import * as vscode from 'vscode';
import { Utils } from 'vscode-uri';
import { getParentDocumentUri } from './dropIntoEditor';
import { getParentDocumentUri } from '../../util/document';
type OverwriteBehavior = 'overwrite' | 'nameIncrementally';
interface CopyFileConfiguration {
readonly destination: Record<string, string>;
readonly overwriteBehavior: OverwriteBehavior;
}
function getCopyFileConfiguration(document: vscode.TextDocument): CopyFileConfiguration {
const config = vscode.workspace.getConfiguration('markdown', document);
return {
destination: config.get<Record<string, string>>('copyFiles.destination') ?? {},
overwriteBehavior: readOverwriteBehavior(config),
};
}
function readOverwriteBehavior(config: vscode.WorkspaceConfiguration): OverwriteBehavior {
switch (config.get('copyFiles.overwriteBehavior')) {
case 'overwrite': return 'overwrite';
default: return 'nameIncrementally';
}
}
export class NewFilePathGenerator {
private readonly _usedPaths = new Set<string>();
async getNewFilePath(document: vscode.TextDocument, file: vscode.DataTransferFile, token: vscode.CancellationToken): Promise<vscode.Uri | undefined> {
const desiredPath = getDesiredNewFilePath(document, file);
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 = path.extname(file.name);
@ -29,13 +56,20 @@ export class NewFilePathGenerator {
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;
return { uri, overwrite: false };
}
}
}
@ -46,10 +80,9 @@ export class NewFilePathGenerator {
}
}
function getDesiredNewFilePath(document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri {
function getDesiredNewFilePath(config: CopyFileConfiguration, document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri {
const docUri = getParentDocumentUri(document);
const config = vscode.workspace.getConfiguration('markdown').get<Record<string, string>>('experimental.copyFiles.destination') ?? {};
for (const [rawGlob, rawDest] of Object.entries(config)) {
for (const [rawGlob, rawDest] of Object.entries(config.destination)) {
for (const glob of parseGlob(rawGlob)) {
if (picomatch.isMatch(docUri.path, glob)) {
return resolveCopyDestination(docUri, file.name, rawDest, uri => vscode.workspace.getWorkspaceFolder(uri)?.uri);

View file

@ -4,18 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { coalesce } from '../../util/arrays';
import { Schemes } from '../../util/schemes';
import { NewFilePathGenerator } from './copyFiles';
import { createUriListSnippet, tryGetUriListSnippet } from './dropIntoEditor';
const supportedImageMimes = new Set([
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
import { createEditForMediaFiles, mediaMimes, tryGetUriListSnippet } from './shared';
class PasteEditProvider implements vscode.DocumentPasteEditProvider {
@ -27,12 +17,12 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider {
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): Promise<vscode.DocumentPasteEdit | undefined> {
const enabled = vscode.workspace.getConfiguration('markdown', document).get('experimental.editor.pasteLinks.enabled', true);
const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.filePaste.enabled', true);
if (!enabled) {
return;
}
const createEdit = await this._makeCreateImagePasteEdit(document, dataTransfer, token);
const createEdit = await this._getMediaFilesEdit(document, dataTransfer, token);
if (createEdit) {
return createEdit;
}
@ -47,56 +37,23 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider {
return uriEdit;
}
private async _makeCreateImagePasteEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentPasteEdit | undefined> {
private async _getMediaFilesEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentPasteEdit | undefined> {
if (document.uri.scheme === Schemes.untitled) {
return;
}
interface FileEntry {
readonly uri: vscode.Uri;
readonly newFileContents?: vscode.DataTransferFile;
}
const pathGenerator = new NewFilePathGenerator();
const fileEntries = coalesce(await Promise.all(Array.from(dataTransfer, async ([mime, item]): Promise<FileEntry | undefined> => {
if (!supportedImageMimes.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 uri = await pathGenerator.getNewFilePath(document, file, token);
return uri ? { uri, newFileContents: file } : undefined;
})));
if (!fileEntries.length) {
const copyFilesIntoWorkspace = vscode.workspace.getConfiguration('markdown', document).get<'mediaFiles' | 'never'>('editor.filePaste.copyIntoWorkspace', 'mediaFiles');
if (copyFilesIntoWorkspace === 'never') {
return;
}
const workspaceEdit = new vscode.WorkspaceEdit();
for (const entry of fileEntries) {
if (entry.newFileContents) {
workspaceEdit.createFile(entry.uri, { contents: entry.newFileContents });
}
}
const snippet = createUriListSnippet(document, fileEntries.map(entry => entry.uri));
if (!snippet) {
const edit = await createEditForMediaFiles(document, dataTransfer, token);
if (!edit) {
return;
}
const pasteEdit = new vscode.DocumentPasteEdit(snippet.snippet, this._id, snippet.label);
pasteEdit.additionalEdit = workspaceEdit;
const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, this._id, edit.label);
pasteEdit.additionalEdit = edit.additionalEdits;
pasteEdit.priority = this._getPriority(dataTransfer);
return pasteEdit;
}
@ -114,7 +71,7 @@ export function registerPasteSupport(selector: vscode.DocumentSelector,) {
return vscode.languages.registerDocumentPasteEditProvider(selector, new PasteEditProvider(), {
pasteMimeTypes: [
'text/uri-list',
...supportedImageMimes,
...mediaMimes,
]
});
}

View file

@ -3,186 +3,71 @@
* 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 * as URI from 'vscode-uri';
import { createEditForMediaFiles as createEditForMediaFiles, tryGetUriListSnippet } from './shared';
import { Schemes } from '../../util/schemes';
export const imageFileExtensions = new Set<string>([
'bmp',
'gif',
'ico',
'jpe',
'jpeg',
'jpg',
'png',
'psd',
'svg',
'tga',
'tif',
'tiff',
'webp',
]);
const videoFileExtensions = new Set<string>([
'ogg',
'mp4'
]);
class MarkdownImageDropProvider implements vscode.DocumentDropEditProvider {
private readonly _id = 'insertLink';
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 snippet = await tryGetUriListSnippet(document, dataTransfer, token);
if (!snippet) {
return undefined;
}
const edit = new vscode.DocumentDropEdit(snippet.snippet);
edit.id = this._id;
edit.label = snippet.label;
return edit;
}
private async _getMediaFilesEdit(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<vscode.DocumentDropEdit | undefined> {
if (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 filesEdit = await createEditForMediaFiles(document, dataTransfer, token);
if (!filesEdit) {
return;
}
const edit = new vscode.DocumentDropEdit(filesEdit.snippet);
edit.id = this._id;
edit.label = filesEdit.label;
edit.additionalEdit = filesEdit.additionalEdits;
return edit;
}
}
export function registerDropIntoEditorSupport(selector: vscode.DocumentSelector) {
return vscode.languages.registerDocumentDropEditProvider(selector, new class implements vscode.DocumentDropEditProvider {
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 undefined;
}
const snippet = await tryGetUriListSnippet(document, dataTransfer, token);
if (!snippet) {
return undefined;
}
const edit = new vscode.DocumentDropEdit(snippet.snippet);
edit.id = 'insertLink';
edit.label = snippet.label;
return edit;
}
}, {
return vscode.languages.registerDocumentDropEditProvider(selector, new MarkdownImageDropProvider(), {
dropMimeTypes: [
'text/uri-list'
]
});
}
export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> {
const urlList = await dataTransfer.get('text/uri-list')?.asString();
if (!urlList || token.isCancellationRequested) {
return undefined;
}
const uris: vscode.Uri[] = [];
for (const resource of urlList.split(/\r?\n/g)) {
try {
uris.push(vscode.Uri.parse(resource));
} catch {
// noop
}
}
return createUriListSnippet(document, uris);
}
interface UriListSnippetOptions {
readonly placeholderText?: string;
readonly placeholderStartIndex?: number;
/**
* Should the snippet be for an image?
*
* If `undefined`, tries to infer this from the uri.
*/
readonly insertAsImage?: boolean;
readonly separator?: string;
}
export function createUriListSnippet(document: vscode.TextDocument, uris: readonly vscode.Uri[], options?: UriListSnippetOptions): { snippet: vscode.SnippetString; label: string } | undefined {
if (!uris.length) {
return undefined;
}
const dir = getDocumentDir(document);
const snippet = new vscode.SnippetString();
let insertedLinkCount = 0;
let insertedImageCount = 0;
uris.forEach((uri, i) => {
const mdPath = getMdPath(dir, uri);
const ext = URI.Utils.extname(uri).toLowerCase().replace('.', '');
const insertAsImage = typeof options?.insertAsImage === 'undefined' ? imageFileExtensions.has(ext) : !!options.insertAsImage;
const insertAsVideo = videoFileExtensions.has(ext);
if (insertAsVideo) {
insertedImageCount++;
snippet.appendText(`<video src="${mdPath}" controls title="`);
snippet.appendPlaceholder('Title');
snippet.appendText('"></video>');
} else {
if (insertAsImage) {
insertedImageCount++;
} else {
insertedLinkCount++;
}
snippet.appendText(insertAsImage ? '![' : '[');
const placeholderText = options?.placeholderText ?? (insertAsImage ? 'Alt text' : 'label');
const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : undefined;
snippet.appendPlaceholder(placeholderText, placeholderIndex);
snippet.appendText(`](${mdPath})`);
}
if (i < uris.length - 1 && uris.length > 1) {
snippet.appendText(options?.separator ?? ' ');
}
});
let label: string;
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 };
}
function getMdPath(dir: vscode.Uri | undefined, file: vscode.Uri) {
if (dir && dir.scheme === file.scheme && dir.authority === file.authority) {
if (file.scheme === Schemes.file) {
// On windows, we must use the native `path.relative` to generate the relative path
// so that drive-letters are resolved cast insensitively. However we then want to
// convert back to a posix path to insert in to the document.
const relativePath = path.relative(dir.fsPath, file.fsPath);
return encodeURI(path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep)));
}
return encodeURI(path.posix.relative(dir.path, file.path));
}
return file.toString(false);
}
function getDocumentDir(document: vscode.TextDocument): vscode.Uri | undefined {
const docUri = getParentDocumentUri(document);
if (docUri.scheme === Schemes.untitled) {
return vscode.workspace.workspaceFolders?.[0]?.uri;
}
return URI.Utils.dirname(docUri);
}
export function getParentDocumentUri(document: vscode.TextDocument): vscode.Uri {
if (document.uri.scheme === Schemes.notebookCell) {
for (const notebook of vscode.workspace.notebookDocuments) {
for (const cell of notebook.getCells()) {
if (cell.document === document) {
return notebook.uri;
}
}
}
}
return document.uri;
}

View file

@ -0,0 +1,234 @@
/*---------------------------------------------------------------------------------------------
* 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 vscode from 'vscode';
import * as URI from 'vscode-uri';
import { Schemes } from '../../util/schemes';
import { NewFilePathGenerator } from './copyFiles';
import { coalesce } from '../../util/arrays';
import { getDocumentDir } from '../../util/document';
enum MediaKind {
Image,
Video,
}
export const mediaFileExtensions = new Map<string, MediaKind>([
// Images
['bmp', MediaKind.Image],
['gif', MediaKind.Image],
['ico', MediaKind.Image],
['jpe', MediaKind.Image],
['jpeg', MediaKind.Image],
['jpg', MediaKind.Image],
['png', MediaKind.Image],
['psd', MediaKind.Image],
['svg', MediaKind.Image],
['tga', MediaKind.Image],
['tif', MediaKind.Image],
['tiff', MediaKind.Image],
['webp', MediaKind.Image],
// Videos
['ogg', MediaKind.Video],
['mp4', MediaKind.Video],
]);
export const mediaMimes = new Set([
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
'video/mp4',
'video/ogg',
]);
export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise<{ snippet: vscode.SnippetString; label: string } | undefined> {
const urlList = await dataTransfer.get('text/uri-list')?.asString();
if (!urlList || token.isCancellationRequested) {
return undefined;
}
const uris: vscode.Uri[] = [];
for (const resource of urlList.split(/\r?\n/g)) {
try {
uris.push(vscode.Uri.parse(resource));
} catch {
// noop
}
}
return createUriListSnippet(document, uris);
}
interface UriListSnippetOptions {
readonly placeholderText?: string;
readonly placeholderStartIndex?: number;
/**
* Should the snippet be for an image link or video?
*
* If `undefined`, tries to infer this from the uri.
*/
readonly insertAsMedia?: boolean;
readonly separator?: string;
}
export function createUriListSnippet(
document: vscode.TextDocument,
uris: readonly vscode.Uri[],
options?: UriListSnippetOptions
): { snippet: vscode.SnippetString; label: string } | undefined {
if (!uris.length) {
return;
}
const dir = getDocumentDir(document);
const snippet = new vscode.SnippetString();
let insertedLinkCount = 0;
let insertedImageCount = 0;
uris.forEach((uri, i) => {
const mdPath = getMdPath(dir, uri);
const ext = URI.Utils.extname(uri).toLowerCase().replace('.', '');
const insertAsMedia = typeof options?.insertAsMedia === 'undefined' ? mediaFileExtensions.has(ext) : !!options.insertAsMedia;
const insertAsVideo = mediaFileExtensions.get(ext) === MediaKind.Video;
if (insertAsVideo) {
insertedImageCount++;
snippet.appendText(`<video src="${mdPath}" controls title="`);
snippet.appendPlaceholder('Title');
snippet.appendText('"></video>');
} else {
if (insertAsMedia) {
insertedImageCount++;
} else {
insertedLinkCount++;
}
snippet.appendText(insertAsMedia ? '![' : '[');
const placeholderText = options?.placeholderText ?? (insertAsMedia ? 'Alt text' : 'label');
const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : undefined;
snippet.appendPlaceholder(placeholderText, placeholderIndex);
snippet.appendText(`](${mdPath})`);
}
if (i < uris.length - 1 && uris.length > 1) {
snippet.appendText(options?.separator ?? ' ');
}
});
let label: string;
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 };
}
/**
* 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.map(entry => entry.uri));
if (!snippet) {
return;
}
return {
snippet: snippet.snippet,
label: snippet.label,
additionalEdits: workspaceEdit,
};
}
function getMdPath(dir: vscode.Uri | undefined, file: vscode.Uri) {
if (dir && dir.scheme === file.scheme && dir.authority === file.authority) {
if (file.scheme === Schemes.file) {
// On windows, we must use the native `path.relative` to generate the relative path
// so that drive-letters are resolved cast insensitively. However we then want to
// convert back to a posix path to insert in to the document.
const relativePath = path.relative(dir.fsPath, file.fsPath);
return encodeURI(path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep)));
}
return encodeURI(path.posix.relative(dir.path, file.path));
}
return file.toString(false);
}

View file

@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* 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 { Schemes } from './schemes';
import { Utils } from 'vscode-uri';
export function getDocumentDir(document: vscode.TextDocument): vscode.Uri | undefined {
const docUri = getParentDocumentUri(document);
if (docUri.scheme === Schemes.untitled) {
return vscode.workspace.workspaceFolders?.[0]?.uri;
}
return Utils.dirname(docUri);
}
export function getParentDocumentUri(document: vscode.TextDocument): vscode.Uri {
if (document.uri.scheme === Schemes.notebookCell) {
for (const notebook of vscode.workspace.notebookDocuments) {
for (const cell of notebook.getCells()) {
if (cell.document === document) {
return notebook.uri;
}
}
}
}
return document.uri;
}