Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add experimental setting to define where files should be copied for markdown #169454

Merged
merged 1 commit into from Dec 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions extensions/markdown-language-features/package.json
Expand Up @@ -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"
}
}
}
},
Expand Down
1 change: 1 addition & 0 deletions extensions/markdown-language-features/package.nls.json
Expand Up @@ -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."
}
Expand Up @@ -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';

Expand Down
4 changes: 2 additions & 2 deletions extensions/markdown-language-features/src/extension.shared.ts
Expand Up @@ -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';
Expand Down
@@ -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<vscode.Uri> {
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<Record<string, string>>('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<string> {
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<string, string>([
['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;
});
}
Expand Up @@ -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([
Expand Down Expand Up @@ -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;
}
Expand All @@ -77,23 +76,6 @@ class PasteEditProvider implements vscode.DocumentPasteEditProvider {
pasteEdit.additionalEdit = workspaceEdit;
return pasteEdit;
}

private async _getNewFileName(document: vscode.TextDocument, file: vscode.DataTransferFile): Promise<vscode.Uri> {
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,) {
Expand Down
Expand Up @@ -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<string>([
'bmp',
Expand Down
77 changes: 77 additions & 0 deletions 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');
});
});