Merge commit 'be3e8236086165e5e45a5a10783823874b3f3ebd' as 'lib/vscode'
This commit is contained in:
@ -0,0 +1,36 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
|
||||
export interface Command {
|
||||
readonly id: string;
|
||||
|
||||
execute(...args: any[]): void;
|
||||
}
|
||||
|
||||
export class CommandManager {
|
||||
private readonly commands = new Map<string, vscode.Disposable>();
|
||||
|
||||
public dispose() {
|
||||
for (const registration of this.commands.values()) {
|
||||
registration.dispose();
|
||||
}
|
||||
this.commands.clear();
|
||||
}
|
||||
|
||||
public register<T extends Command>(command: T): T {
|
||||
this.registerCommand(command.id, command.execute, command);
|
||||
return command;
|
||||
}
|
||||
|
||||
private registerCommand(id: string, impl: (...args: any[]) => void, thisArg?: any) {
|
||||
if (this.commands.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commands.set(id, vscode.commands.registerCommand(id, impl, thisArg));
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export { OpenDocumentLinkCommand } from './openDocumentLink';
|
||||
export { ShowPreviewCommand, ShowPreviewToSideCommand, ShowLockedPreviewToSideCommand } from './showPreview';
|
||||
export { ShowSourceCommand } from './showSource';
|
||||
export { RefreshPreviewCommand } from './refreshPreview';
|
||||
export { ShowPreviewSecuritySelectorCommand } from './showPreviewSecuritySelector';
|
||||
export { MoveCursorToPositionCommand } from './moveCursorToPosition';
|
||||
export { ToggleLockCommand } from './toggleLock';
|
||||
export { RenderDocument } from './renderDocument';
|
@ -0,0 +1,22 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { Command } from '../commandManager';
|
||||
|
||||
export class MoveCursorToPositionCommand implements Command {
|
||||
public readonly id = '_markdown.moveCursorToPosition';
|
||||
|
||||
public execute(line: number, character: number) {
|
||||
if (!vscode.window.activeTextEditor) {
|
||||
return;
|
||||
}
|
||||
const position = new vscode.Position(line, character);
|
||||
const selection = new vscode.Selection(position, position);
|
||||
vscode.window.activeTextEditor.revealRange(selection);
|
||||
vscode.window.activeTextEditor.selection = selection;
|
||||
}
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { extname } from 'path';
|
||||
|
||||
import { Command } from '../commandManager';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { TableOfContentsProvider } from '../tableOfContentsProvider';
|
||||
import { isMarkdownFile } from '../util/file';
|
||||
|
||||
|
||||
export interface OpenDocumentLinkArgs {
|
||||
readonly path: {};
|
||||
readonly fragment: string;
|
||||
readonly fromResource: {};
|
||||
}
|
||||
|
||||
enum OpenMarkdownLinks {
|
||||
beside = 'beside',
|
||||
currentGroup = 'currentGroup',
|
||||
}
|
||||
|
||||
export class OpenDocumentLinkCommand implements Command {
|
||||
private static readonly id = '_markdown.openDocumentLink';
|
||||
public readonly id = OpenDocumentLinkCommand.id;
|
||||
|
||||
public static createCommandUri(
|
||||
fromResource: vscode.Uri,
|
||||
path: vscode.Uri,
|
||||
fragment: string,
|
||||
): vscode.Uri {
|
||||
const toJson = (uri: vscode.Uri) => {
|
||||
return {
|
||||
scheme: uri.scheme,
|
||||
authority: uri.authority,
|
||||
path: uri.path,
|
||||
fragment: uri.fragment,
|
||||
query: uri.query,
|
||||
};
|
||||
};
|
||||
return vscode.Uri.parse(`command:${OpenDocumentLinkCommand.id}?${encodeURIComponent(JSON.stringify(<OpenDocumentLinkArgs>{
|
||||
path: toJson(path),
|
||||
fragment,
|
||||
fromResource: toJson(fromResource),
|
||||
}))}`);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async execute(args: OpenDocumentLinkArgs) {
|
||||
return OpenDocumentLinkCommand.execute(this.engine, args);
|
||||
}
|
||||
|
||||
public static async execute(engine: MarkdownEngine, args: OpenDocumentLinkArgs) {
|
||||
const fromResource = vscode.Uri.parse('').with(args.fromResource);
|
||||
const targetResource = vscode.Uri.parse('').with(args.path);
|
||||
const column = this.getViewColumn(fromResource);
|
||||
try {
|
||||
return await this.tryOpen(engine, targetResource, args, column);
|
||||
} catch {
|
||||
if (extname(targetResource.path) === '') {
|
||||
return this.tryOpen(engine, targetResource.with({ path: targetResource.path + '.md' }), args, column);
|
||||
}
|
||||
await vscode.commands.executeCommand('vscode.open', targetResource, column);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static async tryOpen(engine: MarkdownEngine, resource: vscode.Uri, args: OpenDocumentLinkArgs, column: vscode.ViewColumn) {
|
||||
if (vscode.window.activeTextEditor && isMarkdownFile(vscode.window.activeTextEditor.document)) {
|
||||
if (vscode.window.activeTextEditor.document.uri.fsPath === resource.fsPath) {
|
||||
return this.tryRevealLine(engine, vscode.window.activeTextEditor, args.fragment);
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await vscode.workspace.fs.stat(resource);
|
||||
if (stat.type === vscode.FileType.Directory) {
|
||||
return vscode.commands.executeCommand('revealInExplorer', resource);
|
||||
}
|
||||
|
||||
return vscode.workspace.openTextDocument(resource)
|
||||
.then(document => vscode.window.showTextDocument(document, column))
|
||||
.then(editor => this.tryRevealLine(engine, editor, args.fragment));
|
||||
}
|
||||
|
||||
private static getViewColumn(resource: vscode.Uri): vscode.ViewColumn {
|
||||
const config = vscode.workspace.getConfiguration('markdown', resource);
|
||||
const openLinks = config.get<OpenMarkdownLinks>('links.openLocation', OpenMarkdownLinks.currentGroup);
|
||||
switch (openLinks) {
|
||||
case OpenMarkdownLinks.beside:
|
||||
return vscode.ViewColumn.Beside;
|
||||
case OpenMarkdownLinks.currentGroup:
|
||||
default:
|
||||
return vscode.ViewColumn.Active;
|
||||
}
|
||||
}
|
||||
|
||||
private static async tryRevealLine(engine: MarkdownEngine, editor: vscode.TextEditor, fragment?: string) {
|
||||
if (editor && fragment) {
|
||||
const toc = new TableOfContentsProvider(engine, editor.document);
|
||||
const entry = await toc.lookup(fragment);
|
||||
if (entry) {
|
||||
const lineStart = new vscode.Range(entry.line, 0, entry.line, 0);
|
||||
editor.selection = new vscode.Selection(lineStart.start, lineStart.end);
|
||||
return editor.revealRange(lineStart, vscode.TextEditorRevealType.AtTop);
|
||||
}
|
||||
const lineNumberFragment = fragment.match(/^L(\d+)$/i);
|
||||
if (lineNumberFragment) {
|
||||
const line = +lineNumberFragment[1] - 1;
|
||||
if (!isNaN(line)) {
|
||||
const lineStart = new vscode.Range(line, 0, line, 0);
|
||||
editor.selection = new vscode.Selection(lineStart.start, lineStart.end);
|
||||
return editor.revealRange(lineStart, vscode.TextEditorRevealType.AtTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function resolveLinkToMarkdownFile(path: string): Promise<vscode.Uri | undefined> {
|
||||
try {
|
||||
const standardLink = await tryResolveLinkToMarkdownFile(path);
|
||||
if (standardLink) {
|
||||
return standardLink;
|
||||
}
|
||||
} catch {
|
||||
// Noop
|
||||
}
|
||||
|
||||
// If no extension, try with `.md` extension
|
||||
if (extname(path) === '') {
|
||||
return tryResolveLinkToMarkdownFile(path + '.md');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function tryResolveLinkToMarkdownFile(path: string): Promise<vscode.Uri | undefined> {
|
||||
const resource = vscode.Uri.file(path);
|
||||
|
||||
let document: vscode.TextDocument;
|
||||
try {
|
||||
document = await vscode.workspace.openTextDocument(resource);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (isMarkdownFile(document)) {
|
||||
return document.uri;
|
||||
}
|
||||
return undefined;
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Command } from '../commandManager';
|
||||
import { MarkdownPreviewManager } from '../features/previewManager';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
|
||||
export class RefreshPreviewCommand implements Command {
|
||||
public readonly id = 'markdown.preview.refresh';
|
||||
|
||||
public constructor(
|
||||
private readonly webviewManager: MarkdownPreviewManager,
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public execute() {
|
||||
this.engine.cleanCache();
|
||||
this.webviewManager.refresh();
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Command } from '../commandManager';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { SkinnyTextDocument } from '../tableOfContentsProvider';
|
||||
|
||||
export class RenderDocument implements Command {
|
||||
public readonly id = 'markdown.api.render';
|
||||
|
||||
public constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async execute(document: SkinnyTextDocument | string): Promise<string> {
|
||||
return this.engine.render(document);
|
||||
}
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { Command } from '../commandManager';
|
||||
import { MarkdownPreviewManager, DynamicPreviewSettings } from '../features/previewManager';
|
||||
import { TelemetryReporter } from '../telemetryReporter';
|
||||
|
||||
interface ShowPreviewSettings {
|
||||
readonly sideBySide?: boolean;
|
||||
readonly locked?: boolean;
|
||||
}
|
||||
|
||||
async function showPreview(
|
||||
webviewManager: MarkdownPreviewManager,
|
||||
telemetryReporter: TelemetryReporter,
|
||||
uri: vscode.Uri | undefined,
|
||||
previewSettings: ShowPreviewSettings,
|
||||
): Promise<any> {
|
||||
let resource = uri;
|
||||
if (!(resource instanceof vscode.Uri)) {
|
||||
if (vscode.window.activeTextEditor) {
|
||||
// we are relaxed and don't check for markdown files
|
||||
resource = vscode.window.activeTextEditor.document.uri;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(resource instanceof vscode.Uri)) {
|
||||
if (!vscode.window.activeTextEditor) {
|
||||
// this is most likely toggling the preview
|
||||
return vscode.commands.executeCommand('markdown.showSource');
|
||||
}
|
||||
// nothing found that could be shown or toggled
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceColumn = (vscode.window.activeTextEditor && vscode.window.activeTextEditor.viewColumn) || vscode.ViewColumn.One;
|
||||
webviewManager.openDynamicPreview(resource, {
|
||||
resourceColumn: resourceColumn,
|
||||
previewColumn: previewSettings.sideBySide ? resourceColumn + 1 : resourceColumn,
|
||||
locked: !!previewSettings.locked
|
||||
});
|
||||
|
||||
telemetryReporter.sendTelemetryEvent('openPreview', {
|
||||
where: previewSettings.sideBySide ? 'sideBySide' : 'inPlace',
|
||||
how: (uri instanceof vscode.Uri) ? 'action' : 'pallete'
|
||||
});
|
||||
}
|
||||
|
||||
export class ShowPreviewCommand implements Command {
|
||||
public readonly id = 'markdown.showPreview';
|
||||
|
||||
public constructor(
|
||||
private readonly webviewManager: MarkdownPreviewManager,
|
||||
private readonly telemetryReporter: TelemetryReporter
|
||||
) { }
|
||||
|
||||
public execute(mainUri?: vscode.Uri, allUris?: vscode.Uri[], previewSettings?: DynamicPreviewSettings) {
|
||||
for (const uri of Array.isArray(allUris) ? allUris : [mainUri]) {
|
||||
showPreview(this.webviewManager, this.telemetryReporter, uri, {
|
||||
sideBySide: false,
|
||||
locked: previewSettings && previewSettings.locked
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ShowPreviewToSideCommand implements Command {
|
||||
public readonly id = 'markdown.showPreviewToSide';
|
||||
|
||||
public constructor(
|
||||
private readonly webviewManager: MarkdownPreviewManager,
|
||||
private readonly telemetryReporter: TelemetryReporter
|
||||
) { }
|
||||
|
||||
public execute(uri?: vscode.Uri, previewSettings?: DynamicPreviewSettings) {
|
||||
showPreview(this.webviewManager, this.telemetryReporter, uri, {
|
||||
sideBySide: true,
|
||||
locked: previewSettings && previewSettings.locked
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class ShowLockedPreviewToSideCommand implements Command {
|
||||
public readonly id = 'markdown.showLockedPreviewToSide';
|
||||
|
||||
public constructor(
|
||||
private readonly webviewManager: MarkdownPreviewManager,
|
||||
private readonly telemetryReporter: TelemetryReporter
|
||||
) { }
|
||||
|
||||
public execute(uri?: vscode.Uri) {
|
||||
showPreview(this.webviewManager, this.telemetryReporter, uri, {
|
||||
sideBySide: true,
|
||||
locked: true
|
||||
});
|
||||
}
|
||||
}
|
@ -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 { Command } from '../commandManager';
|
||||
import { PreviewSecuritySelector } from '../security';
|
||||
import { isMarkdownFile } from '../util/file';
|
||||
import { MarkdownPreviewManager } from '../features/previewManager';
|
||||
|
||||
export class ShowPreviewSecuritySelectorCommand implements Command {
|
||||
public readonly id = 'markdown.showPreviewSecuritySelector';
|
||||
|
||||
public constructor(
|
||||
private readonly previewSecuritySelector: PreviewSecuritySelector,
|
||||
private readonly previewManager: MarkdownPreviewManager
|
||||
) { }
|
||||
|
||||
public execute(resource: string | undefined) {
|
||||
if (this.previewManager.activePreviewResource) {
|
||||
this.previewSecuritySelector.showSecuritySelectorForResource(this.previewManager.activePreviewResource);
|
||||
} else if (resource) {
|
||||
const source = vscode.Uri.parse(resource);
|
||||
this.previewSecuritySelector.showSecuritySelectorForResource(source.query ? vscode.Uri.parse(source.query) : source);
|
||||
} else if (vscode.window.activeTextEditor && isMarkdownFile(vscode.window.activeTextEditor.document)) {
|
||||
this.previewSecuritySelector.showSecuritySelectorForResource(vscode.window.activeTextEditor.document.uri);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { Command } from '../commandManager';
|
||||
import { MarkdownPreviewManager } from '../features/previewManager';
|
||||
|
||||
export class ShowSourceCommand implements Command {
|
||||
public readonly id = 'markdown.showSource';
|
||||
|
||||
public constructor(
|
||||
private readonly previewManager: MarkdownPreviewManager
|
||||
) { }
|
||||
|
||||
public execute() {
|
||||
const { activePreviewResource, activePreviewResourceColumn } = this.previewManager;
|
||||
if (activePreviewResource && activePreviewResourceColumn) {
|
||||
return vscode.workspace.openTextDocument(activePreviewResource).then(document => {
|
||||
vscode.window.showTextDocument(document, activePreviewResourceColumn);
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Command } from '../commandManager';
|
||||
import { MarkdownPreviewManager } from '../features/previewManager';
|
||||
|
||||
export class ToggleLockCommand implements Command {
|
||||
public readonly id = 'markdown.preview.toggleLock';
|
||||
|
||||
public constructor(
|
||||
private readonly previewManager: MarkdownPreviewManager
|
||||
) { }
|
||||
|
||||
public execute() {
|
||||
this.previewManager.toggleLock();
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { CommandManager } from './commandManager';
|
||||
import * as commands from './commands/index';
|
||||
import LinkProvider from './features/documentLinkProvider';
|
||||
import MDDocumentSymbolProvider from './features/documentSymbolProvider';
|
||||
import MarkdownFoldingProvider from './features/foldingProvider';
|
||||
import MarkdownSmartSelect from './features/smartSelect';
|
||||
import { MarkdownContentProvider } from './features/previewContentProvider';
|
||||
import { MarkdownPreviewManager } from './features/previewManager';
|
||||
import MarkdownWorkspaceSymbolProvider from './features/workspaceSymbolProvider';
|
||||
import { Logger } from './logger';
|
||||
import { MarkdownEngine } from './markdownEngine';
|
||||
import { getMarkdownExtensionContributions } from './markdownExtensions';
|
||||
import { ContentSecurityPolicyArbiter, ExtensionContentSecurityPolicyArbiter, PreviewSecuritySelector } from './security';
|
||||
import { githubSlugifier } from './slugify';
|
||||
import { loadDefaultTelemetryReporter, TelemetryReporter } from './telemetryReporter';
|
||||
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
const telemetryReporter = loadDefaultTelemetryReporter();
|
||||
context.subscriptions.push(telemetryReporter);
|
||||
|
||||
const contributions = getMarkdownExtensionContributions(context);
|
||||
context.subscriptions.push(contributions);
|
||||
|
||||
const cspArbiter = new ExtensionContentSecurityPolicyArbiter(context.globalState, context.workspaceState);
|
||||
const engine = new MarkdownEngine(contributions, githubSlugifier);
|
||||
const logger = new Logger();
|
||||
|
||||
const contentProvider = new MarkdownContentProvider(engine, context, cspArbiter, contributions, logger);
|
||||
const symbolProvider = new MDDocumentSymbolProvider(engine);
|
||||
const previewManager = new MarkdownPreviewManager(contentProvider, logger, contributions, engine);
|
||||
context.subscriptions.push(previewManager);
|
||||
|
||||
context.subscriptions.push(registerMarkdownLanguageFeatures(symbolProvider, engine));
|
||||
context.subscriptions.push(registerMarkdownCommands(previewManager, telemetryReporter, cspArbiter, engine));
|
||||
|
||||
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => {
|
||||
logger.updateConfiguration();
|
||||
previewManager.updateConfiguration();
|
||||
}));
|
||||
}
|
||||
|
||||
function registerMarkdownLanguageFeatures(
|
||||
symbolProvider: MDDocumentSymbolProvider,
|
||||
engine: MarkdownEngine
|
||||
): vscode.Disposable {
|
||||
const selector: vscode.DocumentSelector = { language: 'markdown', scheme: '*' };
|
||||
|
||||
const charPattern = '(\\p{Alphabetic}|\\p{Number}|\\p{Nonspacing_Mark})';
|
||||
|
||||
return vscode.Disposable.from(
|
||||
vscode.languages.setLanguageConfiguration('markdown', {
|
||||
wordPattern: new RegExp(`${charPattern}((${charPattern}|[_])?${charPattern})*`, 'ug'),
|
||||
}),
|
||||
vscode.languages.registerDocumentSymbolProvider(selector, symbolProvider),
|
||||
vscode.languages.registerDocumentLinkProvider(selector, new LinkProvider()),
|
||||
vscode.languages.registerFoldingRangeProvider(selector, new MarkdownFoldingProvider(engine)),
|
||||
vscode.languages.registerSelectionRangeProvider(selector, new MarkdownSmartSelect(engine)),
|
||||
vscode.languages.registerWorkspaceSymbolProvider(new MarkdownWorkspaceSymbolProvider(symbolProvider))
|
||||
);
|
||||
}
|
||||
|
||||
function registerMarkdownCommands(
|
||||
previewManager: MarkdownPreviewManager,
|
||||
telemetryReporter: TelemetryReporter,
|
||||
cspArbiter: ContentSecurityPolicyArbiter,
|
||||
engine: MarkdownEngine
|
||||
): vscode.Disposable {
|
||||
const previewSecuritySelector = new PreviewSecuritySelector(cspArbiter, previewManager);
|
||||
|
||||
const commandManager = new CommandManager();
|
||||
commandManager.register(new commands.ShowPreviewCommand(previewManager, telemetryReporter));
|
||||
commandManager.register(new commands.ShowPreviewToSideCommand(previewManager, telemetryReporter));
|
||||
commandManager.register(new commands.ShowLockedPreviewToSideCommand(previewManager, telemetryReporter));
|
||||
commandManager.register(new commands.ShowSourceCommand(previewManager));
|
||||
commandManager.register(new commands.RefreshPreviewCommand(previewManager, engine));
|
||||
commandManager.register(new commands.MoveCursorToPositionCommand());
|
||||
commandManager.register(new commands.ShowPreviewSecuritySelectorCommand(previewSecuritySelector, previewManager));
|
||||
commandManager.register(new commands.OpenDocumentLinkCommand(engine));
|
||||
commandManager.register(new commands.ToggleLockCommand(previewManager));
|
||||
commandManager.register(new commands.RenderDocument(engine));
|
||||
return commandManager;
|
||||
}
|
||||
|
@ -0,0 +1,209 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 nls from 'vscode-nls';
|
||||
import { OpenDocumentLinkCommand } from '../commands/openDocumentLink';
|
||||
import { getUriForLinkWithKnownExternalScheme, isOfScheme, Schemes } from '../util/links';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
function parseLink(
|
||||
document: vscode.TextDocument,
|
||||
link: string,
|
||||
): { uri: vscode.Uri, tooltip?: string } | undefined {
|
||||
const externalSchemeUri = getUriForLinkWithKnownExternalScheme(link);
|
||||
if (externalSchemeUri) {
|
||||
// Normalize VS Code links to target currently running version
|
||||
if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {
|
||||
return { uri: vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }) };
|
||||
}
|
||||
return { uri: externalSchemeUri };
|
||||
}
|
||||
|
||||
// Assume it must be an relative or absolute file path
|
||||
// Use a fake scheme to avoid parse warnings
|
||||
const tempUri = vscode.Uri.parse(`vscode-resource:${link}`);
|
||||
|
||||
let resourceUri: vscode.Uri | undefined;
|
||||
if (!tempUri.path) {
|
||||
resourceUri = document.uri;
|
||||
} else if (tempUri.path[0] === '/') {
|
||||
const root = getWorkspaceFolder(document);
|
||||
if (root) {
|
||||
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
|
||||
}
|
||||
} else {
|
||||
if (document.uri.scheme === Schemes.untitled) {
|
||||
const root = getWorkspaceFolder(document);
|
||||
if (root) {
|
||||
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
|
||||
}
|
||||
} else {
|
||||
const base = document.uri.with({ path: path.dirname(document.uri.fsPath) });
|
||||
resourceUri = vscode.Uri.joinPath(base, tempUri.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resourceUri) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
resourceUri = resourceUri.with({ fragment: tempUri.fragment });
|
||||
|
||||
return {
|
||||
uri: OpenDocumentLinkCommand.createCommandUri(document.uri, resourceUri, tempUri.fragment),
|
||||
tooltip: localize('documentLink.tooltip', 'Follow link')
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkspaceFolder(document: vscode.TextDocument) {
|
||||
return vscode.workspace.getWorkspaceFolder(document.uri)?.uri
|
||||
|| vscode.workspace.workspaceFolders?.[0]?.uri;
|
||||
}
|
||||
|
||||
function matchAll(
|
||||
pattern: RegExp,
|
||||
text: string
|
||||
): Array<RegExpMatchArray> {
|
||||
const out: RegExpMatchArray[] = [];
|
||||
pattern.lastIndex = 0;
|
||||
let match: RegExpMatchArray | null;
|
||||
while ((match = pattern.exec(text))) {
|
||||
out.push(match);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractDocumentLink(
|
||||
document: vscode.TextDocument,
|
||||
pre: number,
|
||||
link: string,
|
||||
matchIndex: number | undefined
|
||||
): vscode.DocumentLink | undefined {
|
||||
const offset = (matchIndex || 0) + pre;
|
||||
const linkStart = document.positionAt(offset);
|
||||
const linkEnd = document.positionAt(offset + link.length);
|
||||
try {
|
||||
const linkData = parseLink(document, link);
|
||||
if (!linkData) {
|
||||
return undefined;
|
||||
}
|
||||
const documentLink = new vscode.DocumentLink(
|
||||
new vscode.Range(linkStart, linkEnd),
|
||||
linkData.uri);
|
||||
documentLink.tooltip = linkData.tooltip;
|
||||
return documentLink;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default class LinkProvider implements vscode.DocumentLinkProvider {
|
||||
private readonly linkPattern = /(\[((!\[[^\]]*?\]\(\s*)([^\s\(\)]+?)\s*\)\]|(?:\\\]|[^\]])*\])\(\s*)(([^\s\(\)]|\(\S*?\))+)\s*(".*?")?\)/g;
|
||||
private readonly referenceLinkPattern = /(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]/g;
|
||||
private readonly definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)(\S+)/gm;
|
||||
|
||||
public provideDocumentLinks(
|
||||
document: vscode.TextDocument,
|
||||
_token: vscode.CancellationToken
|
||||
): vscode.DocumentLink[] {
|
||||
const text = document.getText();
|
||||
|
||||
return [
|
||||
...this.providerInlineLinks(text, document),
|
||||
...this.provideReferenceLinks(text, document)
|
||||
];
|
||||
}
|
||||
|
||||
private providerInlineLinks(
|
||||
text: string,
|
||||
document: vscode.TextDocument,
|
||||
): vscode.DocumentLink[] {
|
||||
const results: vscode.DocumentLink[] = [];
|
||||
for (const match of matchAll(this.linkPattern, text)) {
|
||||
const matchImage = match[4] && extractDocumentLink(document, match[3].length + 1, match[4], match.index);
|
||||
if (matchImage) {
|
||||
results.push(matchImage);
|
||||
}
|
||||
const matchLink = extractDocumentLink(document, match[1].length, match[5], match.index);
|
||||
if (matchLink) {
|
||||
results.push(matchLink);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private provideReferenceLinks(
|
||||
text: string,
|
||||
document: vscode.TextDocument,
|
||||
): vscode.DocumentLink[] {
|
||||
const results: vscode.DocumentLink[] = [];
|
||||
|
||||
const definitions = this.getDefinitions(text, document);
|
||||
for (const match of matchAll(this.referenceLinkPattern, text)) {
|
||||
let linkStart: vscode.Position;
|
||||
let linkEnd: vscode.Position;
|
||||
let reference = match[3];
|
||||
if (reference) { // [text][ref]
|
||||
const pre = match[1];
|
||||
const offset = (match.index || 0) + pre.length;
|
||||
linkStart = document.positionAt(offset);
|
||||
linkEnd = document.positionAt(offset + reference.length);
|
||||
} else if (match[2]) { // [ref][]
|
||||
reference = match[2];
|
||||
const offset = (match.index || 0) + 1;
|
||||
linkStart = document.positionAt(offset);
|
||||
linkEnd = document.positionAt(offset + match[2].length);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const link = definitions.get(reference);
|
||||
if (link) {
|
||||
results.push(new vscode.DocumentLink(
|
||||
new vscode.Range(linkStart, linkEnd),
|
||||
vscode.Uri.parse(`command:_markdown.moveCursorToPosition?${encodeURIComponent(JSON.stringify([link.linkRange.start.line, link.linkRange.start.character]))}`)));
|
||||
}
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
for (const definition of definitions.values()) {
|
||||
try {
|
||||
const linkData = parseLink(document, definition.link);
|
||||
if (linkData) {
|
||||
results.push(new vscode.DocumentLink(definition.linkRange, linkData.uri));
|
||||
}
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private getDefinitions(text: string, document: vscode.TextDocument) {
|
||||
const out = new Map<string, { link: string, linkRange: vscode.Range }>();
|
||||
for (const match of matchAll(this.definitionPattern, text)) {
|
||||
const pre = match[1];
|
||||
const reference = match[2];
|
||||
const link = match[3].trim();
|
||||
|
||||
const offset = (match.index || 0) + pre.length;
|
||||
const linkStart = document.positionAt(offset);
|
||||
const linkEnd = document.positionAt(offset + link.length);
|
||||
|
||||
out.set(reference, {
|
||||
link: link,
|
||||
linkRange: new vscode.Range(linkStart, linkEnd)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { MarkdownEngine } from '../markdownEngine';
|
||||
import { TableOfContentsProvider, SkinnyTextDocument, TocEntry } from '../tableOfContentsProvider';
|
||||
|
||||
interface MarkdownSymbol {
|
||||
readonly level: number;
|
||||
readonly parent: MarkdownSymbol | undefined;
|
||||
readonly children: vscode.DocumentSymbol[];
|
||||
}
|
||||
|
||||
export default class MDDocumentSymbolProvider implements vscode.DocumentSymbolProvider {
|
||||
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async provideDocumentSymbolInformation(document: SkinnyTextDocument): Promise<vscode.SymbolInformation[]> {
|
||||
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
|
||||
return toc.map(entry => this.toSymbolInformation(entry));
|
||||
}
|
||||
|
||||
public async provideDocumentSymbols(document: SkinnyTextDocument): Promise<vscode.DocumentSymbol[]> {
|
||||
const toc = await new TableOfContentsProvider(this.engine, document).getToc();
|
||||
const root: MarkdownSymbol = {
|
||||
level: -Infinity,
|
||||
children: [],
|
||||
parent: undefined
|
||||
};
|
||||
this.buildTree(root, toc);
|
||||
return root.children;
|
||||
}
|
||||
|
||||
private buildTree(parent: MarkdownSymbol, entries: TocEntry[]) {
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = entries[0];
|
||||
const symbol = this.toDocumentSymbol(entry);
|
||||
symbol.children = [];
|
||||
|
||||
while (parent && entry.level <= parent.level) {
|
||||
parent = parent.parent!;
|
||||
}
|
||||
parent.children.push(symbol);
|
||||
this.buildTree({ level: entry.level, children: symbol.children, parent }, entries.slice(1));
|
||||
}
|
||||
|
||||
|
||||
private toSymbolInformation(entry: TocEntry): vscode.SymbolInformation {
|
||||
return new vscode.SymbolInformation(
|
||||
this.getSymbolName(entry),
|
||||
vscode.SymbolKind.String,
|
||||
'',
|
||||
entry.location);
|
||||
}
|
||||
|
||||
private toDocumentSymbol(entry: TocEntry) {
|
||||
return new vscode.DocumentSymbol(
|
||||
this.getSymbolName(entry),
|
||||
'',
|
||||
vscode.SymbolKind.String,
|
||||
entry.location.range,
|
||||
entry.location.range);
|
||||
}
|
||||
|
||||
private getSymbolName(entry: TocEntry): string {
|
||||
return '#'.repeat(entry.level) + ' ' + entry.text;
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Token } from 'markdown-it';
|
||||
import * as vscode from 'vscode';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { TableOfContentsProvider } from '../tableOfContentsProvider';
|
||||
import { flatten } from '../util/arrays';
|
||||
|
||||
const rangeLimit = 5000;
|
||||
|
||||
export default class MarkdownFoldingProvider implements vscode.FoldingRangeProvider {
|
||||
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async provideFoldingRanges(
|
||||
document: vscode.TextDocument,
|
||||
_: vscode.FoldingContext,
|
||||
_token: vscode.CancellationToken
|
||||
): Promise<vscode.FoldingRange[]> {
|
||||
const foldables = await Promise.all([
|
||||
this.getRegions(document),
|
||||
this.getHeaderFoldingRanges(document),
|
||||
this.getBlockFoldingRanges(document)
|
||||
]);
|
||||
return flatten(foldables).slice(0, rangeLimit);
|
||||
}
|
||||
|
||||
private async getRegions(document: vscode.TextDocument): Promise<vscode.FoldingRange[]> {
|
||||
const tokens = await this.engine.parse(document);
|
||||
const regionMarkers = tokens.filter(isRegionMarker)
|
||||
.map(token => ({ line: token.map[0], isStart: isStartRegion(token.content) }));
|
||||
|
||||
const nestingStack: { line: number, isStart: boolean }[] = [];
|
||||
return regionMarkers
|
||||
.map(marker => {
|
||||
if (marker.isStart) {
|
||||
nestingStack.push(marker);
|
||||
} else if (nestingStack.length && nestingStack[nestingStack.length - 1].isStart) {
|
||||
return new vscode.FoldingRange(nestingStack.pop()!.line, marker.line, vscode.FoldingRangeKind.Region);
|
||||
} else {
|
||||
// noop: invalid nesting (i.e. [end, start] or [start, end, end])
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((region: vscode.FoldingRange | null): region is vscode.FoldingRange => !!region);
|
||||
}
|
||||
|
||||
private async getHeaderFoldingRanges(document: vscode.TextDocument) {
|
||||
const tocProvider = new TableOfContentsProvider(this.engine, document);
|
||||
const toc = await tocProvider.getToc();
|
||||
return toc.map(entry => {
|
||||
let endLine = entry.location.range.end.line;
|
||||
if (document.lineAt(endLine).isEmptyOrWhitespace && endLine >= entry.line + 1) {
|
||||
endLine = endLine - 1;
|
||||
}
|
||||
return new vscode.FoldingRange(entry.line, endLine);
|
||||
});
|
||||
}
|
||||
|
||||
private async getBlockFoldingRanges(document: vscode.TextDocument): Promise<vscode.FoldingRange[]> {
|
||||
const tokens = await this.engine.parse(document);
|
||||
const multiLineListItems = tokens.filter(isFoldableToken);
|
||||
return multiLineListItems.map(listItem => {
|
||||
const start = listItem.map[0];
|
||||
let end = listItem.map[1] - 1;
|
||||
if (document.lineAt(end).isEmptyOrWhitespace && end >= start + 1) {
|
||||
end = end - 1;
|
||||
}
|
||||
return new vscode.FoldingRange(start, end, this.getFoldingRangeKind(listItem));
|
||||
});
|
||||
}
|
||||
|
||||
private getFoldingRangeKind(listItem: Token): vscode.FoldingRangeKind | undefined {
|
||||
return listItem.type === 'html_block' && listItem.content.startsWith('<!--')
|
||||
? vscode.FoldingRangeKind.Comment
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const isStartRegion = (t: string) => /^\s*<!--\s*#?region\b.*-->/.test(t);
|
||||
const isEndRegion = (t: string) => /^\s*<!--\s*#?endregion\b.*-->/.test(t);
|
||||
|
||||
const isRegionMarker = (token: Token) =>
|
||||
token.type === 'html_block' && (isStartRegion(token.content) || isEndRegion(token.content));
|
||||
|
||||
const isFoldableToken = (token: Token): boolean => {
|
||||
switch (token.type) {
|
||||
case 'fence':
|
||||
case 'list_item_open':
|
||||
return token.map[1] > token.map[0];
|
||||
|
||||
case 'html_block':
|
||||
if (isRegionMarker(token)) {
|
||||
return false;
|
||||
}
|
||||
return token.map[1] > token.map[0] + 1;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
@ -0,0 +1,745 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 nls from 'vscode-nls';
|
||||
import { OpenDocumentLinkCommand, resolveLinkToMarkdownFile } from '../commands/openDocumentLink';
|
||||
import { Logger } from '../logger';
|
||||
import { MarkdownContributionProvider } from '../markdownExtensions';
|
||||
import { Disposable } from '../util/dispose';
|
||||
import { isMarkdownFile } from '../util/file';
|
||||
import { normalizeResource, WebviewResourceProvider } from '../util/resources';
|
||||
import { getVisibleLine, TopmostLineMonitor } from '../util/topmostLineMonitor';
|
||||
import { MarkdownPreviewConfigurationManager } from './previewConfig';
|
||||
import { MarkdownContentProvider } from './previewContentProvider';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
interface WebviewMessage {
|
||||
readonly source: string;
|
||||
}
|
||||
|
||||
interface CacheImageSizesMessage extends WebviewMessage {
|
||||
readonly type: 'cacheImageSizes';
|
||||
readonly body: { id: string, width: number, height: number; }[];
|
||||
}
|
||||
|
||||
interface RevealLineMessage extends WebviewMessage {
|
||||
readonly type: 'revealLine';
|
||||
readonly body: {
|
||||
readonly line: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DidClickMessage extends WebviewMessage {
|
||||
readonly type: 'didClick';
|
||||
readonly body: {
|
||||
readonly line: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ClickLinkMessage extends WebviewMessage {
|
||||
readonly type: 'openLink';
|
||||
readonly body: {
|
||||
readonly href: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShowPreviewSecuritySelectorMessage extends WebviewMessage {
|
||||
readonly type: 'showPreviewSecuritySelector';
|
||||
}
|
||||
|
||||
interface PreviewStyleLoadErrorMessage extends WebviewMessage {
|
||||
readonly type: 'previewStyleLoadError';
|
||||
readonly body: {
|
||||
readonly unloadedStyles: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class PreviewDocumentVersion {
|
||||
|
||||
private readonly resource: vscode.Uri;
|
||||
private readonly version: number;
|
||||
|
||||
public constructor(document: vscode.TextDocument) {
|
||||
this.resource = document.uri;
|
||||
this.version = document.version;
|
||||
}
|
||||
|
||||
public equals(other: PreviewDocumentVersion): boolean {
|
||||
return this.resource.fsPath === other.resource.fsPath
|
||||
&& this.version === other.version;
|
||||
}
|
||||
}
|
||||
|
||||
interface MarkdownPreviewDelegate {
|
||||
getTitle?(resource: vscode.Uri): string;
|
||||
getAdditionalState(): {},
|
||||
openPreviewLinkToMarkdownFile(markdownLink: vscode.Uri, fragment: string): void;
|
||||
}
|
||||
|
||||
class StartingScrollLine {
|
||||
public readonly type = 'line';
|
||||
|
||||
constructor(
|
||||
public readonly line: number,
|
||||
) { }
|
||||
}
|
||||
|
||||
class StartingScrollFragment {
|
||||
public readonly type = 'fragment';
|
||||
|
||||
constructor(
|
||||
public readonly fragment: string,
|
||||
) { }
|
||||
}
|
||||
|
||||
type StartingScrollLocation = StartingScrollLine | StartingScrollFragment;
|
||||
|
||||
class MarkdownPreview extends Disposable implements WebviewResourceProvider {
|
||||
|
||||
private readonly delay = 300;
|
||||
|
||||
private readonly _resource: vscode.Uri;
|
||||
private readonly _webviewPanel: vscode.WebviewPanel;
|
||||
|
||||
private throttleTimer: any;
|
||||
|
||||
private line: number | undefined;
|
||||
private scrollToFragment: string | undefined;
|
||||
|
||||
private firstUpdate = true;
|
||||
private currentVersion?: PreviewDocumentVersion;
|
||||
private isScrolling = false;
|
||||
private _disposed: boolean = false;
|
||||
private imageInfo: { readonly id: string, readonly width: number, readonly height: number; }[] = [];
|
||||
|
||||
constructor(
|
||||
webview: vscode.WebviewPanel,
|
||||
resource: vscode.Uri,
|
||||
startingScroll: StartingScrollLocation | undefined,
|
||||
private readonly delegate: MarkdownPreviewDelegate,
|
||||
private readonly engine: MarkdownEngine,
|
||||
private readonly _contentProvider: MarkdownContentProvider,
|
||||
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
private readonly _logger: Logger,
|
||||
private readonly _contributionProvider: MarkdownContributionProvider,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._webviewPanel = webview;
|
||||
this._resource = resource;
|
||||
|
||||
switch (startingScroll?.type) {
|
||||
case 'line':
|
||||
if (!isNaN(startingScroll.line!)) {
|
||||
this.line = startingScroll.line;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fragment':
|
||||
this.scrollToFragment = startingScroll.fragment;
|
||||
break;
|
||||
}
|
||||
|
||||
this._register(_contributionProvider.onContributionsChanged(() => {
|
||||
setImmediate(() => this.refresh());
|
||||
}));
|
||||
|
||||
this._register(vscode.workspace.onDidChangeTextDocument(event => {
|
||||
if (this.isPreviewOf(event.document.uri)) {
|
||||
this.refresh();
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(this._webviewPanel.webview.onDidReceiveMessage((e: CacheImageSizesMessage | RevealLineMessage | DidClickMessage | ClickLinkMessage | ShowPreviewSecuritySelectorMessage | PreviewStyleLoadErrorMessage) => {
|
||||
if (e.source !== this._resource.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.type) {
|
||||
case 'cacheImageSizes':
|
||||
this.imageInfo = e.body;
|
||||
break;
|
||||
|
||||
case 'revealLine':
|
||||
this.onDidScrollPreview(e.body.line);
|
||||
break;
|
||||
|
||||
case 'didClick':
|
||||
this.onDidClickPreview(e.body.line);
|
||||
break;
|
||||
|
||||
case 'openLink':
|
||||
this.onDidClickPreviewLink(e.body.href);
|
||||
break;
|
||||
|
||||
case 'showPreviewSecuritySelector':
|
||||
vscode.commands.executeCommand('markdown.showPreviewSecuritySelector', e.source);
|
||||
break;
|
||||
|
||||
case 'previewStyleLoadError':
|
||||
vscode.window.showWarningMessage(
|
||||
localize('onPreviewStyleLoadError',
|
||||
"Could not load 'markdown.styles': {0}",
|
||||
e.body.unloadedStyles.join(', ')));
|
||||
break;
|
||||
}
|
||||
}));
|
||||
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
super.dispose();
|
||||
this._disposed = true;
|
||||
clearTimeout(this.throttleTimer);
|
||||
}
|
||||
|
||||
public get resource(): vscode.Uri {
|
||||
return this._resource;
|
||||
}
|
||||
|
||||
public get state() {
|
||||
return {
|
||||
resource: this._resource.toString(),
|
||||
line: this.line,
|
||||
imageInfo: this.imageInfo,
|
||||
fragment: this.scrollToFragment,
|
||||
...this.delegate.getAdditionalState(),
|
||||
};
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
// Schedule update if none is pending
|
||||
if (!this.throttleTimer) {
|
||||
if (this.firstUpdate) {
|
||||
this.updatePreview(true);
|
||||
} else {
|
||||
this.throttleTimer = setTimeout(() => this.updatePreview(true), this.delay);
|
||||
}
|
||||
}
|
||||
|
||||
this.firstUpdate = false;
|
||||
}
|
||||
|
||||
private get iconPath() {
|
||||
const root = vscode.Uri.joinPath(this._contributionProvider.extensionUri, 'media');
|
||||
return {
|
||||
light: vscode.Uri.joinPath(root, 'preview-light.svg'),
|
||||
dark: vscode.Uri.joinPath(root, 'preview-dark.svg'),
|
||||
};
|
||||
}
|
||||
|
||||
public isPreviewOf(resource: vscode.Uri): boolean {
|
||||
return this._resource.fsPath === resource.fsPath;
|
||||
}
|
||||
|
||||
public postMessage(msg: any) {
|
||||
if (!this._disposed) {
|
||||
this._webviewPanel.webview.postMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
public scrollTo(topLine: number) {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isScrolling) {
|
||||
this.isScrolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.log('updateForView', { markdownFile: this._resource });
|
||||
this.line = topLine;
|
||||
this.postMessage({
|
||||
type: 'updateView',
|
||||
line: topLine,
|
||||
source: this._resource.toString()
|
||||
});
|
||||
}
|
||||
|
||||
private async updatePreview(forceUpdate?: boolean): Promise<void> {
|
||||
clearTimeout(this.throttleTimer);
|
||||
this.throttleTimer = undefined;
|
||||
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let document: vscode.TextDocument;
|
||||
try {
|
||||
document = await vscode.workspace.openTextDocument(this._resource);
|
||||
} catch {
|
||||
await this.showFileNotFoundError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingVersion = new PreviewDocumentVersion(document);
|
||||
if (!forceUpdate && this.currentVersion?.equals(pendingVersion)) {
|
||||
if (this.line) {
|
||||
this.scrollTo(this.line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentVersion = pendingVersion;
|
||||
const content = await this._contentProvider.provideTextDocumentContent(document, this, this._previewConfigurations, this.line, this.state);
|
||||
|
||||
// Another call to `doUpdate` may have happened.
|
||||
// Make sure we are still updating for the correct document
|
||||
if (this.currentVersion?.equals(pendingVersion)) {
|
||||
this.setContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
private onDidScrollPreview(line: number) {
|
||||
this.line = line;
|
||||
|
||||
const config = this._previewConfigurations.loadAndCacheConfiguration(this._resource);
|
||||
if (!config.scrollEditorWithPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (!this.isPreviewOf(editor.document.uri)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.isScrolling = true;
|
||||
const sourceLine = Math.floor(line);
|
||||
const fraction = line - sourceLine;
|
||||
const text = editor.document.lineAt(sourceLine).text;
|
||||
const start = Math.floor(fraction * text.length);
|
||||
editor.revealRange(
|
||||
new vscode.Range(sourceLine, start, sourceLine + 1, 0),
|
||||
vscode.TextEditorRevealType.AtTop);
|
||||
}
|
||||
}
|
||||
|
||||
private async onDidClickPreview(line: number): Promise<void> {
|
||||
// fix #82457, find currently opened but unfocused source tab
|
||||
await vscode.commands.executeCommand('markdown.showSource');
|
||||
|
||||
for (const visibleEditor of vscode.window.visibleTextEditors) {
|
||||
if (this.isPreviewOf(visibleEditor.document.uri)) {
|
||||
const editor = await vscode.window.showTextDocument(visibleEditor.document, visibleEditor.viewColumn);
|
||||
const position = new vscode.Position(line, 0);
|
||||
editor.selection = new vscode.Selection(position, position);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
vscode.workspace.openTextDocument(this._resource)
|
||||
.then(vscode.window.showTextDocument)
|
||||
.then(undefined, () => {
|
||||
vscode.window.showErrorMessage(localize('preview.clickOpenFailed', 'Could not open {0}', this._resource.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
private async showFileNotFoundError() {
|
||||
this._webviewPanel.webview.html = this._contentProvider.provideFileNotFoundContent(this._resource);
|
||||
}
|
||||
|
||||
private setContent(html: string): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.delegate.getTitle) {
|
||||
this._webviewPanel.title = this.delegate.getTitle(this._resource);
|
||||
}
|
||||
this._webviewPanel.iconPath = this.iconPath;
|
||||
this._webviewPanel.webview.options = this.getWebviewOptions();
|
||||
|
||||
this._webviewPanel.webview.html = html;
|
||||
}
|
||||
|
||||
private getWebviewOptions(): vscode.WebviewOptions {
|
||||
return {
|
||||
enableScripts: true,
|
||||
localResourceRoots: this.getLocalResourceRoots()
|
||||
};
|
||||
}
|
||||
|
||||
private getLocalResourceRoots(): ReadonlyArray<vscode.Uri> {
|
||||
const baseRoots = Array.from(this._contributionProvider.contributions.previewResourceRoots);
|
||||
|
||||
const folder = vscode.workspace.getWorkspaceFolder(this._resource);
|
||||
if (folder) {
|
||||
const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri);
|
||||
if (workspaceRoots) {
|
||||
baseRoots.push(...workspaceRoots);
|
||||
}
|
||||
} else if (!this._resource.scheme || this._resource.scheme === 'file') {
|
||||
baseRoots.push(vscode.Uri.file(path.dirname(this._resource.fsPath)));
|
||||
}
|
||||
|
||||
return baseRoots.map(root => normalizeResource(this._resource, root));
|
||||
}
|
||||
|
||||
|
||||
private async onDidClickPreviewLink(href: string) {
|
||||
let [hrefPath, fragment] = decodeURIComponent(href).split('#');
|
||||
|
||||
// We perviously already resolve absolute paths.
|
||||
// Now make sure we handle relative file paths
|
||||
if (hrefPath[0] !== '/') {
|
||||
// Fix #93691, use this.resource.fsPath instead of this.resource.path
|
||||
hrefPath = path.join(path.dirname(this.resource.fsPath), hrefPath);
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('markdown', this.resource);
|
||||
const openLinks = config.get<string>('preview.openMarkdownLinks', 'inPreview');
|
||||
if (openLinks === 'inPreview') {
|
||||
const markdownLink = await resolveLinkToMarkdownFile(hrefPath);
|
||||
if (markdownLink) {
|
||||
this.delegate.openPreviewLinkToMarkdownFile(markdownLink, fragment);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
OpenDocumentLinkCommand.execute(this.engine, { path: hrefPath, fragment, fromResource: this.resource.toJSON() });
|
||||
}
|
||||
|
||||
//#region WebviewResourceProvider
|
||||
|
||||
asWebviewUri(resource: vscode.Uri) {
|
||||
return this._webviewPanel.webview.asWebviewUri(normalizeResource(this._resource, resource));
|
||||
}
|
||||
|
||||
get cspSource() {
|
||||
return this._webviewPanel.webview.cspSource;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export interface ManagedMarkdownPreview {
|
||||
|
||||
readonly resource: vscode.Uri;
|
||||
readonly resourceColumn: vscode.ViewColumn;
|
||||
|
||||
readonly onDispose: vscode.Event<void>;
|
||||
readonly onDidChangeViewState: vscode.Event<vscode.WebviewPanelOnDidChangeViewStateEvent>;
|
||||
|
||||
dispose(): void;
|
||||
|
||||
refresh(): void;
|
||||
updateConfiguration(): void;
|
||||
|
||||
matchesResource(
|
||||
otherResource: vscode.Uri,
|
||||
otherPosition: vscode.ViewColumn | undefined,
|
||||
otherLocked: boolean
|
||||
): boolean;
|
||||
}
|
||||
|
||||
export class StaticMarkdownPreview extends Disposable implements ManagedMarkdownPreview {
|
||||
|
||||
public static revive(
|
||||
resource: vscode.Uri,
|
||||
webview: vscode.WebviewPanel,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
): StaticMarkdownPreview {
|
||||
return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, logger, contributionProvider, engine);
|
||||
}
|
||||
|
||||
private readonly preview: MarkdownPreview;
|
||||
|
||||
private constructor(
|
||||
private readonly _webviewPanel: vscode.WebviewPanel,
|
||||
resource: vscode.Uri,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.preview = this._register(new MarkdownPreview(this._webviewPanel, resource, undefined, {
|
||||
getAdditionalState: () => { return {}; },
|
||||
openPreviewLinkToMarkdownFile: () => { /* todo */ }
|
||||
}, engine, contentProvider, _previewConfigurations, logger, contributionProvider));
|
||||
|
||||
this._register(this._webviewPanel.onDidDispose(() => {
|
||||
this.dispose();
|
||||
}));
|
||||
|
||||
this._register(this._webviewPanel.onDidChangeViewState(e => {
|
||||
this._onDidChangeViewState.fire(e);
|
||||
}));
|
||||
}
|
||||
|
||||
private readonly _onDispose = this._register(new vscode.EventEmitter<void>());
|
||||
public readonly onDispose = this._onDispose.event;
|
||||
|
||||
private readonly _onDidChangeViewState = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());
|
||||
public readonly onDidChangeViewState = this._onDidChangeViewState.event;
|
||||
|
||||
dispose() {
|
||||
this._onDispose.fire();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public matchesResource(
|
||||
_otherResource: vscode.Uri,
|
||||
_otherPosition: vscode.ViewColumn | undefined,
|
||||
_otherLocked: boolean
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
this.preview.refresh();
|
||||
}
|
||||
|
||||
public updateConfiguration() {
|
||||
if (this._previewConfigurations.hasConfigurationChanged(this.preview.resource)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public get resource() {
|
||||
return this.preview.resource;
|
||||
}
|
||||
|
||||
public get resourceColumn() {
|
||||
return this._webviewPanel.viewColumn || vscode.ViewColumn.One;
|
||||
}
|
||||
}
|
||||
|
||||
interface DynamicPreviewInput {
|
||||
readonly resource: vscode.Uri;
|
||||
readonly resourceColumn: vscode.ViewColumn;
|
||||
readonly locked: boolean;
|
||||
readonly line?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A
|
||||
*/
|
||||
export class DynamicMarkdownPreview extends Disposable implements ManagedMarkdownPreview {
|
||||
|
||||
public static readonly viewType = 'markdown.preview';
|
||||
|
||||
private readonly _resourceColumn: vscode.ViewColumn;
|
||||
private _locked: boolean;
|
||||
|
||||
private readonly _webviewPanel: vscode.WebviewPanel;
|
||||
private _preview: MarkdownPreview;
|
||||
|
||||
public static revive(
|
||||
input: DynamicPreviewInput,
|
||||
webview: vscode.WebviewPanel,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
topmostLineMonitor: TopmostLineMonitor,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
): DynamicMarkdownPreview {
|
||||
return new DynamicMarkdownPreview(webview, input,
|
||||
contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, engine);
|
||||
}
|
||||
|
||||
public static create(
|
||||
input: DynamicPreviewInput,
|
||||
previewColumn: vscode.ViewColumn,
|
||||
contentProvider: MarkdownContentProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
logger: Logger,
|
||||
topmostLineMonitor: TopmostLineMonitor,
|
||||
contributionProvider: MarkdownContributionProvider,
|
||||
engine: MarkdownEngine,
|
||||
): DynamicMarkdownPreview {
|
||||
const webview = vscode.window.createWebviewPanel(
|
||||
DynamicMarkdownPreview.viewType,
|
||||
DynamicMarkdownPreview.getPreviewTitle(input.resource, input.locked),
|
||||
previewColumn, { enableFindWidget: true, });
|
||||
|
||||
return new DynamicMarkdownPreview(webview, input,
|
||||
contentProvider, previewConfigurations, logger, topmostLineMonitor, contributionProvider, engine);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
webview: vscode.WebviewPanel,
|
||||
input: DynamicPreviewInput,
|
||||
private readonly _contentProvider: MarkdownContentProvider,
|
||||
private readonly _previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
private readonly _logger: Logger,
|
||||
private readonly _topmostLineMonitor: TopmostLineMonitor,
|
||||
private readonly _contributionProvider: MarkdownContributionProvider,
|
||||
private readonly _engine: MarkdownEngine,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._webviewPanel = webview;
|
||||
|
||||
this._resourceColumn = input.resourceColumn;
|
||||
this._locked = input.locked;
|
||||
|
||||
this._preview = this.createPreview(input.resource, typeof input.line === 'number' ? new StartingScrollLine(input.line) : undefined);
|
||||
|
||||
this._register(webview.onDidDispose(() => { this.dispose(); }));
|
||||
|
||||
this._register(this._webviewPanel.onDidChangeViewState(e => {
|
||||
this._onDidChangeViewStateEmitter.fire(e);
|
||||
}));
|
||||
|
||||
this._register(this._topmostLineMonitor.onDidChanged(event => {
|
||||
if (this._preview.isPreviewOf(event.resource)) {
|
||||
this._preview.scrollTo(event.line);
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(vscode.window.onDidChangeTextEditorSelection(event => {
|
||||
if (this._preview.isPreviewOf(event.textEditor.document.uri)) {
|
||||
this._preview.postMessage({
|
||||
type: 'onDidChangeTextEditorSelection',
|
||||
line: event.selections[0].active.line,
|
||||
source: this._preview.resource.toString()
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
// Only allow previewing normal text editors which have a viewColumn: See #101514
|
||||
if (typeof editor?.viewColumn === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkdownFile(editor.document) && !this._locked && !this._preview.isPreviewOf(editor.document.uri)) {
|
||||
const line = getVisibleLine(editor);
|
||||
this.update(editor.document.uri, line ? new StartingScrollLine(line) : undefined);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private readonly _onDisposeEmitter = this._register(new vscode.EventEmitter<void>());
|
||||
public readonly onDispose = this._onDisposeEmitter.event;
|
||||
|
||||
private readonly _onDidChangeViewStateEmitter = this._register(new vscode.EventEmitter<vscode.WebviewPanelOnDidChangeViewStateEvent>());
|
||||
public readonly onDidChangeViewState = this._onDidChangeViewStateEmitter.event;
|
||||
|
||||
dispose() {
|
||||
this._preview.dispose();
|
||||
this._webviewPanel.dispose();
|
||||
|
||||
this._onDisposeEmitter.fire();
|
||||
this._onDisposeEmitter.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public get resource() {
|
||||
return this._preview.resource;
|
||||
}
|
||||
|
||||
public get resourceColumn() {
|
||||
return this._resourceColumn;
|
||||
}
|
||||
|
||||
public reveal(viewColumn: vscode.ViewColumn) {
|
||||
this._webviewPanel.reveal(viewColumn);
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
this._preview.refresh();
|
||||
}
|
||||
|
||||
public updateConfiguration() {
|
||||
if (this._previewConfigurations.hasConfigurationChanged(this._preview.resource)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public update(newResource: vscode.Uri, scrollLocation?: StartingScrollLocation) {
|
||||
if (this._preview.isPreviewOf(newResource)) {
|
||||
switch (scrollLocation?.type) {
|
||||
case 'line':
|
||||
this._preview.scrollTo(scrollLocation.line);
|
||||
return;
|
||||
|
||||
case 'fragment':
|
||||
// Workaround. For fragments, just reload the entire preview
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this._preview.dispose();
|
||||
this._preview = this.createPreview(newResource, scrollLocation);
|
||||
}
|
||||
|
||||
public toggleLock() {
|
||||
this._locked = !this._locked;
|
||||
this._webviewPanel.title = DynamicMarkdownPreview.getPreviewTitle(this._preview.resource, this._locked);
|
||||
}
|
||||
|
||||
private static getPreviewTitle(resource: vscode.Uri, locked: boolean): string {
|
||||
return locked
|
||||
? localize('lockedPreviewTitle', '[Preview] {0}', path.basename(resource.fsPath))
|
||||
: localize('previewTitle', 'Preview {0}', path.basename(resource.fsPath));
|
||||
}
|
||||
|
||||
public get position(): vscode.ViewColumn | undefined {
|
||||
return this._webviewPanel.viewColumn;
|
||||
}
|
||||
|
||||
public matchesResource(
|
||||
otherResource: vscode.Uri,
|
||||
otherPosition: vscode.ViewColumn | undefined,
|
||||
otherLocked: boolean
|
||||
): boolean {
|
||||
if (this.position !== otherPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._locked) {
|
||||
return otherLocked && this._preview.isPreviewOf(otherResource);
|
||||
} else {
|
||||
return !otherLocked;
|
||||
}
|
||||
}
|
||||
|
||||
public matches(otherPreview: DynamicMarkdownPreview): boolean {
|
||||
return this.matchesResource(otherPreview._preview.resource, otherPreview.position, otherPreview._locked);
|
||||
}
|
||||
|
||||
private createPreview(resource: vscode.Uri, startingScroll?: StartingScrollLocation): MarkdownPreview {
|
||||
return new MarkdownPreview(this._webviewPanel, resource, startingScroll, {
|
||||
getTitle: (resource) => DynamicMarkdownPreview.getPreviewTitle(resource, this._locked),
|
||||
getAdditionalState: () => {
|
||||
return {
|
||||
resourceColumn: this.resourceColumn,
|
||||
locked: this._locked,
|
||||
};
|
||||
},
|
||||
openPreviewLinkToMarkdownFile: (link: vscode.Uri, fragment?: string) => {
|
||||
this.update(link, fragment ? new StartingScrollFragment(fragment) : undefined);
|
||||
}
|
||||
},
|
||||
this._engine,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._contributionProvider);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,93 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { equals } from '../util/arrays';
|
||||
|
||||
export class MarkdownPreviewConfiguration {
|
||||
public static getForResource(resource: vscode.Uri) {
|
||||
return new MarkdownPreviewConfiguration(resource);
|
||||
}
|
||||
|
||||
public readonly scrollBeyondLastLine: boolean;
|
||||
public readonly wordWrap: boolean;
|
||||
public readonly lineBreaks: boolean;
|
||||
public readonly doubleClickToSwitchToEditor: boolean;
|
||||
public readonly scrollEditorWithPreview: boolean;
|
||||
public readonly scrollPreviewWithEditor: boolean;
|
||||
public readonly markEditorSelection: boolean;
|
||||
|
||||
public readonly lineHeight: number;
|
||||
public readonly fontSize: number;
|
||||
public readonly fontFamily: string | undefined;
|
||||
public readonly styles: readonly string[];
|
||||
|
||||
private constructor(resource: vscode.Uri) {
|
||||
const editorConfig = vscode.workspace.getConfiguration('editor', resource);
|
||||
const markdownConfig = vscode.workspace.getConfiguration('markdown', resource);
|
||||
const markdownEditorConfig = vscode.workspace.getConfiguration('[markdown]', resource);
|
||||
|
||||
this.scrollBeyondLastLine = editorConfig.get<boolean>('scrollBeyondLastLine', false);
|
||||
|
||||
this.wordWrap = editorConfig.get<string>('wordWrap', 'off') !== 'off';
|
||||
if (markdownEditorConfig && markdownEditorConfig['editor.wordWrap']) {
|
||||
this.wordWrap = markdownEditorConfig['editor.wordWrap'] !== 'off';
|
||||
}
|
||||
|
||||
this.scrollPreviewWithEditor = !!markdownConfig.get<boolean>('preview.scrollPreviewWithEditor', true);
|
||||
this.scrollEditorWithPreview = !!markdownConfig.get<boolean>('preview.scrollEditorWithPreview', true);
|
||||
this.lineBreaks = !!markdownConfig.get<boolean>('preview.breaks', false);
|
||||
this.doubleClickToSwitchToEditor = !!markdownConfig.get<boolean>('preview.doubleClickToSwitchToEditor', true);
|
||||
this.markEditorSelection = !!markdownConfig.get<boolean>('preview.markEditorSelection', true);
|
||||
|
||||
this.fontFamily = markdownConfig.get<string | undefined>('preview.fontFamily', undefined);
|
||||
this.fontSize = Math.max(8, +markdownConfig.get<number>('preview.fontSize', NaN));
|
||||
this.lineHeight = Math.max(0.6, +markdownConfig.get<number>('preview.lineHeight', NaN));
|
||||
|
||||
this.styles = markdownConfig.get<string[]>('styles', []);
|
||||
}
|
||||
|
||||
public isEqualTo(otherConfig: MarkdownPreviewConfiguration) {
|
||||
for (const key in this) {
|
||||
if (this.hasOwnProperty(key) && key !== 'styles') {
|
||||
if (this[key] !== otherConfig[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return equals(this.styles, otherConfig.styles);
|
||||
}
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class MarkdownPreviewConfigurationManager {
|
||||
private readonly previewConfigurationsForWorkspaces = new Map<string, MarkdownPreviewConfiguration>();
|
||||
|
||||
public loadAndCacheConfiguration(
|
||||
resource: vscode.Uri
|
||||
): MarkdownPreviewConfiguration {
|
||||
const config = MarkdownPreviewConfiguration.getForResource(resource);
|
||||
this.previewConfigurationsForWorkspaces.set(this.getKey(resource), config);
|
||||
return config;
|
||||
}
|
||||
|
||||
public hasConfigurationChanged(
|
||||
resource: vscode.Uri
|
||||
): boolean {
|
||||
const key = this.getKey(resource);
|
||||
const currentConfig = this.previewConfigurationsForWorkspaces.get(key);
|
||||
const newConfig = MarkdownPreviewConfiguration.getForResource(resource);
|
||||
return (!currentConfig || !currentConfig.isEqualTo(newConfig));
|
||||
}
|
||||
|
||||
private getKey(
|
||||
resource: vscode.Uri
|
||||
): string {
|
||||
const folder = vscode.workspace.getWorkspaceFolder(resource);
|
||||
return folder ? folder.uri.toString() : '';
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 nls from 'vscode-nls';
|
||||
import { Logger } from '../logger';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { MarkdownContributionProvider } from '../markdownExtensions';
|
||||
import { ContentSecurityPolicyArbiter, MarkdownPreviewSecurityLevel } from '../security';
|
||||
import { WebviewResourceProvider } from '../util/resources';
|
||||
import { MarkdownPreviewConfiguration, MarkdownPreviewConfigurationManager } from './previewConfig';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
/**
|
||||
* Strings used inside the markdown preview.
|
||||
*
|
||||
* Stored here and then injected in the preview so that they
|
||||
* can be localized using our normal localization process.
|
||||
*/
|
||||
const previewStrings = {
|
||||
cspAlertMessageText: localize(
|
||||
'preview.securityMessage.text',
|
||||
'Some content has been disabled in this document'),
|
||||
|
||||
cspAlertMessageTitle: localize(
|
||||
'preview.securityMessage.title',
|
||||
'Potentially unsafe or insecure content has been disabled in the markdown preview. Change the Markdown preview security setting to allow insecure content or enable scripts'),
|
||||
|
||||
cspAlertMessageLabel: localize(
|
||||
'preview.securityMessage.label',
|
||||
'Content Disabled Security Warning')
|
||||
};
|
||||
|
||||
function escapeAttribute(value: string | vscode.Uri): string {
|
||||
return value.toString().replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export class MarkdownContentProvider {
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine,
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly cspArbiter: ContentSecurityPolicyArbiter,
|
||||
private readonly contributionProvider: MarkdownContributionProvider,
|
||||
private readonly logger: Logger
|
||||
) { }
|
||||
|
||||
public async provideTextDocumentContent(
|
||||
markdownDocument: vscode.TextDocument,
|
||||
resourceProvider: WebviewResourceProvider,
|
||||
previewConfigurations: MarkdownPreviewConfigurationManager,
|
||||
initialLine: number | undefined = undefined,
|
||||
state?: any
|
||||
): Promise<string> {
|
||||
const sourceUri = markdownDocument.uri;
|
||||
const config = previewConfigurations.loadAndCacheConfiguration(sourceUri);
|
||||
const initialData = {
|
||||
source: sourceUri.toString(),
|
||||
line: initialLine,
|
||||
lineCount: markdownDocument.lineCount,
|
||||
scrollPreviewWithEditor: config.scrollPreviewWithEditor,
|
||||
scrollEditorWithPreview: config.scrollEditorWithPreview,
|
||||
doubleClickToSwitchToEditor: config.doubleClickToSwitchToEditor,
|
||||
disableSecurityWarnings: this.cspArbiter.shouldDisableSecurityWarnings(),
|
||||
webviewResourceRoot: resourceProvider.asWebviewUri(markdownDocument.uri).toString(),
|
||||
};
|
||||
|
||||
this.logger.log('provideTextDocumentContent', initialData);
|
||||
|
||||
// Content Security Policy
|
||||
const nonce = new Date().getTime() + '' + new Date().getMilliseconds();
|
||||
const csp = this.getCsp(resourceProvider, sourceUri, nonce);
|
||||
|
||||
const body = await this.engine.render(markdownDocument);
|
||||
return `<!DOCTYPE html>
|
||||
<html style="${escapeAttribute(this.getSettingsOverrideStyles(config))}">
|
||||
<head>
|
||||
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
||||
${csp}
|
||||
<meta id="vscode-markdown-preview-data"
|
||||
data-settings="${escapeAttribute(JSON.stringify(initialData))}"
|
||||
data-strings="${escapeAttribute(JSON.stringify(previewStrings))}"
|
||||
data-state="${escapeAttribute(JSON.stringify(state || {}))}">
|
||||
<script src="${this.extensionResourcePath(resourceProvider, 'pre.js')}" nonce="${nonce}"></script>
|
||||
${this.getStyles(resourceProvider, sourceUri, config, state)}
|
||||
<base href="${resourceProvider.asWebviewUri(markdownDocument.uri)}">
|
||||
</head>
|
||||
<body class="vscode-body ${config.scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${config.wordWrap ? 'wordWrap' : ''} ${config.markEditorSelection ? 'showEditorSelection' : ''}">
|
||||
${body}
|
||||
<div class="code-line" data-line="${markdownDocument.lineCount}"></div>
|
||||
${this.getScripts(resourceProvider, nonce)}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
public provideFileNotFoundContent(
|
||||
resource: vscode.Uri,
|
||||
): string {
|
||||
const resourcePath = path.basename(resource.fsPath);
|
||||
const body = localize('preview.notFound', '{0} cannot be found', resourcePath);
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<body class="vscode-body">
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string {
|
||||
const webviewResource = resourceProvider.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.context.extensionUri, 'media', mediaFile));
|
||||
return webviewResource.toString();
|
||||
}
|
||||
|
||||
private fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string {
|
||||
if (!href) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (href.startsWith('http:') || href.startsWith('https:') || href.startsWith('file:')) {
|
||||
return href;
|
||||
}
|
||||
|
||||
// Assume it must be a local file
|
||||
if (path.isAbsolute(href)) {
|
||||
return resourceProvider.asWebviewUri(vscode.Uri.file(href)).toString();
|
||||
}
|
||||
|
||||
// Use a workspace relative path if there is a workspace
|
||||
const root = vscode.workspace.getWorkspaceFolder(resource);
|
||||
if (root) {
|
||||
return resourceProvider.asWebviewUri(vscode.Uri.joinPath(root.uri, href)).toString();
|
||||
}
|
||||
|
||||
// Otherwise look relative to the markdown file
|
||||
return resourceProvider.asWebviewUri(vscode.Uri.file(path.join(path.dirname(resource.fsPath), href))).toString();
|
||||
}
|
||||
|
||||
private computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string {
|
||||
if (!Array.isArray(config.styles)) {
|
||||
return '';
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const style of config.styles) {
|
||||
out.push(`<link rel="stylesheet" class="code-user-style" data-source="${escapeAttribute(style)}" href="${escapeAttribute(this.fixHref(resourceProvider, resource, style))}" type="text/css" media="screen">`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
private getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string {
|
||||
return [
|
||||
config.fontFamily ? `--markdown-font-family: ${config.fontFamily};` : '',
|
||||
isNaN(config.fontSize) ? '' : `--markdown-font-size: ${config.fontSize}px;`,
|
||||
isNaN(config.lineHeight) ? '' : `--markdown-line-height: ${config.lineHeight};`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
private getImageStabilizerStyles(state?: any) {
|
||||
let ret = '<style>\n';
|
||||
if (state && state.imageInfo) {
|
||||
state.imageInfo.forEach((imgInfo: any) => {
|
||||
ret += `#${imgInfo.id}.loading {
|
||||
height: ${imgInfo.height}px;
|
||||
width: ${imgInfo.width}px;
|
||||
}\n`;
|
||||
});
|
||||
}
|
||||
ret += '</style>\n';
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, state?: any): string {
|
||||
const baseStyles: string[] = [];
|
||||
for (const resource of this.contributionProvider.contributions.previewStyles) {
|
||||
baseStyles.push(`<link rel="stylesheet" type="text/css" href="${escapeAttribute(resourceProvider.asWebviewUri(resource))}">`);
|
||||
}
|
||||
|
||||
return `${baseStyles.join('\n')}
|
||||
${this.computeCustomStyleSheetIncludes(resourceProvider, resource, config)}
|
||||
${this.getImageStabilizerStyles(state)}`;
|
||||
}
|
||||
|
||||
private getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string {
|
||||
const out: string[] = [];
|
||||
for (const resource of this.contributionProvider.contributions.previewScripts) {
|
||||
out.push(`<script async
|
||||
src="${escapeAttribute(resourceProvider.asWebviewUri(resource))}"
|
||||
nonce="${nonce}"
|
||||
charset="UTF-8"></script>`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
private getCsp(
|
||||
provider: WebviewResourceProvider,
|
||||
resource: vscode.Uri,
|
||||
nonce: string
|
||||
): string {
|
||||
const rule = provider.cspSource;
|
||||
switch (this.cspArbiter.getSecurityLevelForResource(resource)) {
|
||||
case MarkdownPreviewSecurityLevel.AllowInsecureContent:
|
||||
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} http: https: data:; media-src 'self' ${rule} http: https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' http: https: data:; font-src 'self' ${rule} http: https: data:;">`;
|
||||
|
||||
case MarkdownPreviewSecurityLevel.AllowInsecureLocalContent:
|
||||
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; media-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data: http://localhost:* http://127.0.0.1:*; font-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*;">`;
|
||||
|
||||
case MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent:
|
||||
return '<meta http-equiv="Content-Security-Policy" content="">';
|
||||
|
||||
case MarkdownPreviewSecurityLevel.Strict:
|
||||
default:
|
||||
return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' ${rule} https: data:; media-src 'self' ${rule} https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data:; font-src 'self' ${rule} https: data:;">`;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,239 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { Logger } from '../logger';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { MarkdownContributionProvider } from '../markdownExtensions';
|
||||
import { Disposable, disposeAll } from '../util/dispose';
|
||||
import { TopmostLineMonitor } from '../util/topmostLineMonitor';
|
||||
import { DynamicMarkdownPreview, ManagedMarkdownPreview, StaticMarkdownPreview } from './preview';
|
||||
import { MarkdownPreviewConfigurationManager } from './previewConfig';
|
||||
import { MarkdownContentProvider } from './previewContentProvider';
|
||||
|
||||
export interface DynamicPreviewSettings {
|
||||
readonly resourceColumn: vscode.ViewColumn;
|
||||
readonly previewColumn: vscode.ViewColumn;
|
||||
readonly locked: boolean;
|
||||
}
|
||||
|
||||
class PreviewStore<T extends ManagedMarkdownPreview> extends Disposable {
|
||||
|
||||
private readonly _previews = new Set<T>();
|
||||
|
||||
public dispose(): void {
|
||||
super.dispose();
|
||||
for (const preview of this._previews) {
|
||||
preview.dispose();
|
||||
}
|
||||
this._previews.clear();
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<T> {
|
||||
return this._previews[Symbol.iterator]();
|
||||
}
|
||||
|
||||
public get(resource: vscode.Uri, previewSettings: DynamicPreviewSettings): T | undefined {
|
||||
for (const preview of this._previews) {
|
||||
if (preview.matchesResource(resource, previewSettings.previewColumn, previewSettings.locked)) {
|
||||
return preview;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public add(preview: T) {
|
||||
this._previews.add(preview);
|
||||
}
|
||||
|
||||
public delete(preview: T) {
|
||||
this._previews.delete(preview);
|
||||
}
|
||||
}
|
||||
|
||||
export class MarkdownPreviewManager extends Disposable implements vscode.WebviewPanelSerializer, vscode.CustomTextEditorProvider {
|
||||
private static readonly markdownPreviewActiveContextKey = 'markdownPreviewFocus';
|
||||
|
||||
private readonly _topmostLineMonitor = new TopmostLineMonitor();
|
||||
private readonly _previewConfigurations = new MarkdownPreviewConfigurationManager();
|
||||
|
||||
private readonly _dynamicPreviews = this._register(new PreviewStore<DynamicMarkdownPreview>());
|
||||
private readonly _staticPreviews = this._register(new PreviewStore<StaticMarkdownPreview>());
|
||||
|
||||
private _activePreview: ManagedMarkdownPreview | undefined = undefined;
|
||||
|
||||
private readonly customEditorViewType = 'vscode.markdown.preview.editor';
|
||||
|
||||
public constructor(
|
||||
private readonly _contentProvider: MarkdownContentProvider,
|
||||
private readonly _logger: Logger,
|
||||
private readonly _contributions: MarkdownContributionProvider,
|
||||
private readonly _engine: MarkdownEngine,
|
||||
) {
|
||||
super();
|
||||
this._register(vscode.window.registerWebviewPanelSerializer(DynamicMarkdownPreview.viewType, this));
|
||||
this._register(vscode.window.registerCustomEditorProvider(this.customEditorViewType, this));
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
for (const preview of this._dynamicPreviews) {
|
||||
preview.refresh();
|
||||
}
|
||||
for (const preview of this._staticPreviews) {
|
||||
preview.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public updateConfiguration() {
|
||||
for (const preview of this._dynamicPreviews) {
|
||||
preview.updateConfiguration();
|
||||
}
|
||||
for (const preview of this._staticPreviews) {
|
||||
preview.updateConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
public openDynamicPreview(
|
||||
resource: vscode.Uri,
|
||||
settings: DynamicPreviewSettings
|
||||
): void {
|
||||
let preview = this._dynamicPreviews.get(resource, settings);
|
||||
if (preview) {
|
||||
preview.reveal(settings.previewColumn);
|
||||
} else {
|
||||
preview = this.createNewDynamicPreview(resource, settings);
|
||||
}
|
||||
|
||||
preview.update(resource);
|
||||
}
|
||||
|
||||
public get activePreviewResource() {
|
||||
return this._activePreview?.resource;
|
||||
}
|
||||
|
||||
public get activePreviewResourceColumn() {
|
||||
return this._activePreview?.resourceColumn;
|
||||
}
|
||||
|
||||
public toggleLock() {
|
||||
const preview = this._activePreview;
|
||||
if (preview instanceof DynamicMarkdownPreview) {
|
||||
preview.toggleLock();
|
||||
|
||||
// Close any previews that are now redundant, such as having two dynamic previews in the same editor group
|
||||
for (const otherPreview of this._dynamicPreviews) {
|
||||
if (otherPreview !== preview && preview.matches(otherPreview)) {
|
||||
otherPreview.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deserializeWebviewPanel(
|
||||
webview: vscode.WebviewPanel,
|
||||
state: any
|
||||
): Promise<void> {
|
||||
const resource = vscode.Uri.parse(state.resource);
|
||||
const locked = state.locked;
|
||||
const line = state.line;
|
||||
const resourceColumn = state.resourceColumn;
|
||||
|
||||
const preview = await DynamicMarkdownPreview.revive(
|
||||
{ resource, locked, line, resourceColumn },
|
||||
webview,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._topmostLineMonitor,
|
||||
this._contributions,
|
||||
this._engine);
|
||||
|
||||
this.registerDynamicPreview(preview);
|
||||
}
|
||||
|
||||
public async resolveCustomTextEditor(
|
||||
document: vscode.TextDocument,
|
||||
webview: vscode.WebviewPanel
|
||||
): Promise<void> {
|
||||
const preview = StaticMarkdownPreview.revive(
|
||||
document.uri,
|
||||
webview,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._contributions,
|
||||
this._engine);
|
||||
this.registerStaticPreview(preview);
|
||||
}
|
||||
|
||||
private createNewDynamicPreview(
|
||||
resource: vscode.Uri,
|
||||
previewSettings: DynamicPreviewSettings
|
||||
): DynamicMarkdownPreview {
|
||||
const preview = DynamicMarkdownPreview.create(
|
||||
{
|
||||
resource,
|
||||
resourceColumn: previewSettings.resourceColumn,
|
||||
locked: previewSettings.locked,
|
||||
},
|
||||
previewSettings.previewColumn,
|
||||
this._contentProvider,
|
||||
this._previewConfigurations,
|
||||
this._logger,
|
||||
this._topmostLineMonitor,
|
||||
this._contributions,
|
||||
this._engine);
|
||||
|
||||
this.setPreviewActiveContext(true);
|
||||
this._activePreview = preview;
|
||||
return this.registerDynamicPreview(preview);
|
||||
}
|
||||
|
||||
private registerDynamicPreview(preview: DynamicMarkdownPreview): DynamicMarkdownPreview {
|
||||
this._dynamicPreviews.add(preview);
|
||||
|
||||
preview.onDispose(() => {
|
||||
this._dynamicPreviews.delete(preview);
|
||||
});
|
||||
|
||||
this.trackActive(preview);
|
||||
|
||||
preview.onDidChangeViewState(() => {
|
||||
// Remove other dynamic previews in our column
|
||||
disposeAll(Array.from(this._dynamicPreviews).filter(otherPreview => preview !== otherPreview && preview.matches(otherPreview)));
|
||||
});
|
||||
return preview;
|
||||
}
|
||||
|
||||
private registerStaticPreview(preview: StaticMarkdownPreview): StaticMarkdownPreview {
|
||||
this._staticPreviews.add(preview);
|
||||
|
||||
preview.onDispose(() => {
|
||||
this._staticPreviews.delete(preview);
|
||||
});
|
||||
|
||||
this.trackActive(preview);
|
||||
return preview;
|
||||
}
|
||||
|
||||
private trackActive(preview: ManagedMarkdownPreview): void {
|
||||
preview.onDidChangeViewState(({ webviewPanel }) => {
|
||||
this.setPreviewActiveContext(webviewPanel.active);
|
||||
this._activePreview = webviewPanel.active ? preview : undefined;
|
||||
});
|
||||
|
||||
preview.onDispose(() => {
|
||||
if (this._activePreview === preview) {
|
||||
this.setPreviewActiveContext(false);
|
||||
this._activePreview = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setPreviewActiveContext(value: boolean) {
|
||||
vscode.commands.executeCommand('setContext', MarkdownPreviewManager.markdownPreviewActiveContextKey, value);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,238 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Token } from 'markdown-it';
|
||||
import * as vscode from 'vscode';
|
||||
import { MarkdownEngine } from '../markdownEngine';
|
||||
import { TableOfContentsProvider, TocEntry } from '../tableOfContentsProvider';
|
||||
|
||||
export default class MarkdownSmartSelect implements vscode.SelectionRangeProvider {
|
||||
|
||||
constructor(
|
||||
private readonly engine: MarkdownEngine
|
||||
) { }
|
||||
|
||||
public async provideSelectionRanges(document: vscode.TextDocument, positions: vscode.Position[], _token: vscode.CancellationToken): Promise<vscode.SelectionRange[] | undefined> {
|
||||
let promises = await Promise.all(positions.map((position) => {
|
||||
return this.provideSelectionRange(document, position, _token);
|
||||
}));
|
||||
return promises.filter(item => item !== undefined) as vscode.SelectionRange[];
|
||||
}
|
||||
|
||||
private async provideSelectionRange(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise<vscode.SelectionRange | undefined> {
|
||||
const headerRange = await this.getHeaderSelectionRange(document, position);
|
||||
const blockRange = await this.getBlockSelectionRange(document, position, headerRange);
|
||||
return blockRange ? blockRange : headerRange ? headerRange : undefined;
|
||||
}
|
||||
|
||||
private async getBlockSelectionRange(document: vscode.TextDocument, position: vscode.Position, headerRange?: vscode.SelectionRange): Promise<vscode.SelectionRange | undefined> {
|
||||
|
||||
const tokens = await this.engine.parse(document);
|
||||
|
||||
let blockTokens = getTokensForPosition(tokens, position);
|
||||
|
||||
if (blockTokens.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let parentRange = headerRange ? headerRange : createBlockRange(document, position.line, blockTokens.shift());
|
||||
let currentRange: vscode.SelectionRange | undefined;
|
||||
|
||||
for (const token of blockTokens) {
|
||||
currentRange = createBlockRange(document, position.line, token, parentRange);
|
||||
if (currentRange) {
|
||||
parentRange = currentRange;
|
||||
}
|
||||
}
|
||||
if (currentRange) {
|
||||
return currentRange;
|
||||
} else {
|
||||
return parentRange;
|
||||
}
|
||||
}
|
||||
|
||||
private async getHeaderSelectionRange(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.SelectionRange | undefined> {
|
||||
const tocProvider = new TableOfContentsProvider(this.engine, document);
|
||||
const toc = await tocProvider.getToc();
|
||||
|
||||
let headerInfo = getHeadersForPosition(toc, position);
|
||||
|
||||
let headers = headerInfo.headers;
|
||||
|
||||
let parentRange: vscode.SelectionRange | undefined;
|
||||
let currentRange: vscode.SelectionRange | undefined;
|
||||
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
currentRange = createHeaderRange(i === headers.length - 1, headerInfo.headerOnThisLine, headers[i], parentRange, getFirstChildHeader(document, headers[i], toc));
|
||||
if (currentRange && currentRange.parent) {
|
||||
parentRange = currentRange;
|
||||
}
|
||||
}
|
||||
return currentRange;
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstChildHeader(document: vscode.TextDocument, header?: TocEntry, toc?: TocEntry[]): vscode.Position | undefined {
|
||||
let childRange: vscode.Position | undefined;
|
||||
if (header && toc) {
|
||||
let children = toc.filter(t => header.location.range.contains(t.location.range) && t.location.range.start.line > header.location.range.start.line).sort((t1, t2) => t1.line - t2.line);
|
||||
if (children.length > 0) {
|
||||
childRange = children[0].location.range.start;
|
||||
let lineText = document.lineAt(childRange.line - 1).text;
|
||||
return childRange ? childRange.translate(-1, lineText.length) : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getTokensForPosition(tokens: Token[], position: vscode.Position): Token[] {
|
||||
let enclosingTokens = tokens.filter(token => token.map && (token.map[0] <= position.line && token.map[1] > position.line) && isBlockElement(token));
|
||||
|
||||
if (enclosingTokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let sortedTokens = enclosingTokens.sort((token1, token2) => (token2.map[1] - token2.map[0]) - (token1.map[1] - token1.map[0]));
|
||||
return sortedTokens;
|
||||
}
|
||||
|
||||
function getHeadersForPosition(toc: TocEntry[], position: vscode.Position): { headers: TocEntry[], headerOnThisLine: boolean } {
|
||||
let enclosingHeaders = toc.filter(header => header.location.range.start.line <= position.line && header.location.range.end.line >= position.line);
|
||||
let sortedHeaders = enclosingHeaders.sort((header1, header2) => (header1.line - position.line) - (header2.line - position.line));
|
||||
let onThisLine = toc.find(header => header.line === position.line) !== undefined;
|
||||
return {
|
||||
headers: sortedHeaders,
|
||||
headerOnThisLine: onThisLine
|
||||
};
|
||||
}
|
||||
|
||||
function isBlockElement(token: Token): boolean {
|
||||
return !['list_item_close', 'paragraph_close', 'bullet_list_close', 'inline', 'heading_close', 'heading_open'].includes(token.type);
|
||||
}
|
||||
|
||||
function createHeaderRange(isClosestHeaderToPosition: boolean, onHeaderLine: boolean, header?: TocEntry, parent?: vscode.SelectionRange, childStart?: vscode.Position): vscode.SelectionRange | undefined {
|
||||
if (header) {
|
||||
let contentRange = new vscode.Range(header.location.range.start.translate(1), header.location.range.end);
|
||||
let headerPlusContentRange = header.location.range;
|
||||
let partialContentRange = childStart && isClosestHeaderToPosition ? contentRange.with(undefined, childStart) : undefined;
|
||||
if (onHeaderLine && isClosestHeaderToPosition && childStart) {
|
||||
return new vscode.SelectionRange(header.location.range.with(undefined, childStart), new vscode.SelectionRange(header.location.range, parent));
|
||||
} else if (onHeaderLine && isClosestHeaderToPosition) {
|
||||
return new vscode.SelectionRange(header.location.range, parent);
|
||||
} else if (parent && parent.range.contains(headerPlusContentRange)) {
|
||||
if (partialContentRange) {
|
||||
return new vscode.SelectionRange(partialContentRange, new vscode.SelectionRange(contentRange, (new vscode.SelectionRange(headerPlusContentRange, parent))));
|
||||
} else {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(headerPlusContentRange, parent));
|
||||
}
|
||||
} else if (partialContentRange) {
|
||||
return new vscode.SelectionRange(partialContentRange, new vscode.SelectionRange(contentRange, (new vscode.SelectionRange(headerPlusContentRange))));
|
||||
} else {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(headerPlusContentRange));
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createBlockRange(document: vscode.TextDocument, cursorLine: number, block?: Token, parent?: vscode.SelectionRange): vscode.SelectionRange | undefined {
|
||||
if (block) {
|
||||
if (block.type === 'fence') {
|
||||
return createFencedRange(block, cursorLine, document, parent);
|
||||
} else {
|
||||
let startLine = document.lineAt(block.map[0]).isEmptyOrWhitespace ? block.map[0] + 1 : block.map[0];
|
||||
let endLine = startLine !== block.map[1] && isList(block.type) ? block.map[1] - 1 : block.map[1];
|
||||
let startPos = new vscode.Position(startLine, 0);
|
||||
let endPos = new vscode.Position(endLine, getEndCharacter(document, startLine, endLine));
|
||||
let range = new vscode.Range(startPos, endPos);
|
||||
if (parent && parent.range.contains(range) && !parent.range.isEqual(range)) {
|
||||
return new vscode.SelectionRange(range, parent);
|
||||
} else if (parent) {
|
||||
if (rangeLinesEqual(range, parent.range)) {
|
||||
return range.end.character > parent.range.end.character ? new vscode.SelectionRange(range) : parent;
|
||||
} else if (parent.range.end.line + 1 === range.end.line) {
|
||||
let adjustedRange = new vscode.Range(range.start, range.end.translate(-1, parent.range.end.character));
|
||||
if (adjustedRange.isEqual(parent.range)) {
|
||||
return parent;
|
||||
} else {
|
||||
return new vscode.SelectionRange(adjustedRange, parent);
|
||||
}
|
||||
} else if (parent.range.end.line === range.end.line) {
|
||||
let adjustedRange = new vscode.Range(parent.range.start, range.end.translate(undefined, parent.range.end.character));
|
||||
if (adjustedRange.isEqual(parent.range)) {
|
||||
return parent;
|
||||
} else {
|
||||
return new vscode.SelectionRange(adjustedRange, parent.parent);
|
||||
}
|
||||
} else {
|
||||
return parent;
|
||||
}
|
||||
} else {
|
||||
return new vscode.SelectionRange(range);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createFencedRange(token: Token, cursorLine: number, document: vscode.TextDocument, parent?: vscode.SelectionRange): vscode.SelectionRange {
|
||||
const startLine = token.map[0];
|
||||
const endLine = token.map[1] - 1;
|
||||
let onFenceLine = cursorLine === startLine || cursorLine === endLine;
|
||||
let fenceRange = new vscode.Range(new vscode.Position(startLine, 0), new vscode.Position(endLine, document.lineAt(endLine).text.length));
|
||||
let contentRange = endLine - startLine > 2 && !onFenceLine ? new vscode.Range(new vscode.Position(startLine + 1, 0), new vscode.Position(endLine - 1, getEndCharacter(document, startLine + 1, endLine))) : undefined;
|
||||
if (parent && contentRange) {
|
||||
if (parent.range.contains(fenceRange) && !parent.range.isEqual(fenceRange)) {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange, parent));
|
||||
} else if (parent.range.isEqual(fenceRange)) {
|
||||
return new vscode.SelectionRange(contentRange, parent);
|
||||
} else if (rangeLinesEqual(fenceRange, parent.range)) {
|
||||
let revisedRange = fenceRange.end.character > parent.range.end.character ? fenceRange : parent.range;
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(revisedRange, getRealParent(parent, revisedRange)));
|
||||
} else if (parent.range.end.line === fenceRange.end.line) {
|
||||
parent.range.end.translate(undefined, fenceRange.end.character);
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange, parent));
|
||||
}
|
||||
} else if (contentRange) {
|
||||
return new vscode.SelectionRange(contentRange, new vscode.SelectionRange(fenceRange));
|
||||
} else if (parent) {
|
||||
if (parent.range.contains(fenceRange) && !parent.range.isEqual(fenceRange)) {
|
||||
return new vscode.SelectionRange(fenceRange, parent);
|
||||
} else if (parent.range.isEqual(fenceRange)) {
|
||||
return parent;
|
||||
} else if (rangeLinesEqual(fenceRange, parent.range)) {
|
||||
let revisedRange = fenceRange.end.character > parent.range.end.character ? fenceRange : parent.range;
|
||||
return new vscode.SelectionRange(revisedRange, parent.parent);
|
||||
} else if (parent.range.end.line === fenceRange.end.line) {
|
||||
parent.range.end.translate(undefined, fenceRange.end.character);
|
||||
return new vscode.SelectionRange(fenceRange, parent);
|
||||
}
|
||||
}
|
||||
return new vscode.SelectionRange(fenceRange, parent);
|
||||
}
|
||||
|
||||
function isList(type: string): boolean {
|
||||
return type ? ['ordered_list_open', 'list_item_open', 'bullet_list_open'].includes(type) : false;
|
||||
}
|
||||
|
||||
function getEndCharacter(document: vscode.TextDocument, startLine: number, endLine: number): number {
|
||||
let startLength = document.lineAt(startLine).text ? document.lineAt(startLine).text.length : 0;
|
||||
let endLength = document.lineAt(startLine).text ? document.lineAt(startLine).text.length : 0;
|
||||
let endChar = Math.max(startLength, endLength);
|
||||
return startLine !== endLine ? 0 : endChar;
|
||||
}
|
||||
|
||||
function getRealParent(parent: vscode.SelectionRange, range: vscode.Range) {
|
||||
let currentParent: vscode.SelectionRange | undefined = parent;
|
||||
while (currentParent && !currentParent.range.contains(range)) {
|
||||
currentParent = currentParent.parent;
|
||||
}
|
||||
return currentParent;
|
||||
}
|
||||
|
||||
function rangeLinesEqual(range: vscode.Range, parent: vscode.Range) {
|
||||
return range.start.line === parent.start.line && range.end.line === parent.end.line;
|
||||
}
|
@ -0,0 +1,163 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { Disposable } from '../util/dispose';
|
||||
import { isMarkdownFile } from '../util/file';
|
||||
import { Lazy, lazy } from '../util/lazy';
|
||||
import MDDocumentSymbolProvider from './documentSymbolProvider';
|
||||
import { SkinnyTextDocument, SkinnyTextLine } from '../tableOfContentsProvider';
|
||||
import { flatten } from '../util/arrays';
|
||||
|
||||
export interface WorkspaceMarkdownDocumentProvider {
|
||||
getAllMarkdownDocuments(): Thenable<Iterable<SkinnyTextDocument>>;
|
||||
|
||||
readonly onDidChangeMarkdownDocument: vscode.Event<SkinnyTextDocument>;
|
||||
readonly onDidCreateMarkdownDocument: vscode.Event<SkinnyTextDocument>;
|
||||
readonly onDidDeleteMarkdownDocument: vscode.Event<vscode.Uri>;
|
||||
}
|
||||
|
||||
class VSCodeWorkspaceMarkdownDocumentProvider extends Disposable implements WorkspaceMarkdownDocumentProvider {
|
||||
|
||||
private readonly _onDidChangeMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<SkinnyTextDocument>());
|
||||
private readonly _onDidCreateMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<SkinnyTextDocument>());
|
||||
private readonly _onDidDeleteMarkdownDocumentEmitter = this._register(new vscode.EventEmitter<vscode.Uri>());
|
||||
|
||||
private _watcher: vscode.FileSystemWatcher | undefined;
|
||||
|
||||
async getAllMarkdownDocuments() {
|
||||
const resources = await vscode.workspace.findFiles('**/*.md', '**/node_modules/**');
|
||||
const docs = await Promise.all(resources.map(doc => this.getMarkdownDocument(doc)));
|
||||
return docs.filter(doc => !!doc) as SkinnyTextDocument[];
|
||||
}
|
||||
|
||||
public get onDidChangeMarkdownDocument() {
|
||||
this.ensureWatcher();
|
||||
return this._onDidChangeMarkdownDocumentEmitter.event;
|
||||
}
|
||||
|
||||
public get onDidCreateMarkdownDocument() {
|
||||
this.ensureWatcher();
|
||||
return this._onDidCreateMarkdownDocumentEmitter.event;
|
||||
}
|
||||
|
||||
public get onDidDeleteMarkdownDocument() {
|
||||
this.ensureWatcher();
|
||||
return this._onDidDeleteMarkdownDocumentEmitter.event;
|
||||
}
|
||||
|
||||
private ensureWatcher(): void {
|
||||
if (this._watcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._watcher = this._register(vscode.workspace.createFileSystemWatcher('**/*.md'));
|
||||
|
||||
this._watcher.onDidChange(async resource => {
|
||||
const document = await this.getMarkdownDocument(resource);
|
||||
if (document) {
|
||||
this._onDidChangeMarkdownDocumentEmitter.fire(document);
|
||||
}
|
||||
}, null, this._disposables);
|
||||
|
||||
this._watcher.onDidCreate(async resource => {
|
||||
const document = await this.getMarkdownDocument(resource);
|
||||
if (document) {
|
||||
this._onDidCreateMarkdownDocumentEmitter.fire(document);
|
||||
}
|
||||
}, null, this._disposables);
|
||||
|
||||
this._watcher.onDidDelete(async resource => {
|
||||
this._onDidDeleteMarkdownDocumentEmitter.fire(resource);
|
||||
}, null, this._disposables);
|
||||
|
||||
vscode.workspace.onDidChangeTextDocument(e => {
|
||||
if (isMarkdownFile(e.document)) {
|
||||
this._onDidChangeMarkdownDocumentEmitter.fire(e.document);
|
||||
}
|
||||
}, null, this._disposables);
|
||||
}
|
||||
|
||||
private async getMarkdownDocument(resource: vscode.Uri): Promise<SkinnyTextDocument | undefined> {
|
||||
const matchingDocuments = vscode.workspace.textDocuments.filter((doc) => doc.uri.toString() === resource.toString());
|
||||
if (matchingDocuments.length !== 0) {
|
||||
return matchingDocuments[0];
|
||||
}
|
||||
|
||||
const bytes = await vscode.workspace.fs.readFile(resource);
|
||||
|
||||
// We assume that markdown is in UTF-8
|
||||
const text = Buffer.from(bytes).toString('utf-8');
|
||||
|
||||
const lines: SkinnyTextLine[] = [];
|
||||
const parts = text.split(/(\r?\n)/);
|
||||
const lineCount = Math.floor(parts.length / 2) + 1;
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
lines.push({
|
||||
text: parts[line * 2]
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
uri: resource,
|
||||
version: 0,
|
||||
lineCount: lineCount,
|
||||
lineAt: (index) => {
|
||||
return lines[index];
|
||||
},
|
||||
getText: () => {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default class MarkdownWorkspaceSymbolProvider extends Disposable implements vscode.WorkspaceSymbolProvider {
|
||||
private _symbolCache = new Map<string, Lazy<Thenable<vscode.SymbolInformation[]>>>();
|
||||
private _symbolCachePopulated: boolean = false;
|
||||
|
||||
public constructor(
|
||||
private _symbolProvider: MDDocumentSymbolProvider,
|
||||
private _workspaceMarkdownDocumentProvider: WorkspaceMarkdownDocumentProvider = new VSCodeWorkspaceMarkdownDocumentProvider()
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async provideWorkspaceSymbols(query: string): Promise<vscode.SymbolInformation[]> {
|
||||
if (!this._symbolCachePopulated) {
|
||||
await this.populateSymbolCache();
|
||||
this._symbolCachePopulated = true;
|
||||
|
||||
this._workspaceMarkdownDocumentProvider.onDidChangeMarkdownDocument(this.onDidChangeDocument, this, this._disposables);
|
||||
this._workspaceMarkdownDocumentProvider.onDidCreateMarkdownDocument(this.onDidChangeDocument, this, this._disposables);
|
||||
this._workspaceMarkdownDocumentProvider.onDidDeleteMarkdownDocument(this.onDidDeleteDocument, this, this._disposables);
|
||||
}
|
||||
|
||||
const allSymbolsSets = await Promise.all(Array.from(this._symbolCache.values()).map(x => x.value));
|
||||
const allSymbols = flatten(allSymbolsSets);
|
||||
return allSymbols.filter(symbolInformation => symbolInformation.name.toLowerCase().indexOf(query.toLowerCase()) !== -1);
|
||||
}
|
||||
|
||||
public async populateSymbolCache(): Promise<void> {
|
||||
const markdownDocumentUris = await this._workspaceMarkdownDocumentProvider.getAllMarkdownDocuments();
|
||||
for (const document of markdownDocumentUris) {
|
||||
this._symbolCache.set(document.uri.fsPath, this.getSymbols(document));
|
||||
}
|
||||
}
|
||||
|
||||
private getSymbols(document: SkinnyTextDocument): Lazy<Thenable<vscode.SymbolInformation[]>> {
|
||||
return lazy(async () => {
|
||||
return this._symbolProvider.provideDocumentSymbolInformation(document);
|
||||
});
|
||||
}
|
||||
|
||||
private onDidChangeDocument(document: SkinnyTextDocument) {
|
||||
this._symbolCache.set(document.uri.fsPath, this.getSymbols(document));
|
||||
}
|
||||
|
||||
private onDidDeleteDocument(resource: vscode.Uri) {
|
||||
this._symbolCache.delete(resource.fsPath);
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { lazy } from './util/lazy';
|
||||
|
||||
enum Trace {
|
||||
Off,
|
||||
Verbose
|
||||
}
|
||||
|
||||
namespace Trace {
|
||||
export function fromString(value: string): Trace {
|
||||
value = value.toLowerCase();
|
||||
switch (value) {
|
||||
case 'off':
|
||||
return Trace.Off;
|
||||
case 'verbose':
|
||||
return Trace.Verbose;
|
||||
default:
|
||||
return Trace.Off;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function isString(value: any): value is string {
|
||||
return Object.prototype.toString.call(value) === '[object String]';
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private trace?: Trace;
|
||||
|
||||
private readonly outputChannel = lazy(() => vscode.window.createOutputChannel('Markdown'));
|
||||
|
||||
constructor() {
|
||||
this.updateConfiguration();
|
||||
}
|
||||
|
||||
public log(message: string, data?: any): void {
|
||||
if (this.trace === Trace.Verbose) {
|
||||
this.appendLine(`[Log - ${this.now()}] ${message}`);
|
||||
if (data) {
|
||||
this.appendLine(Logger.data2String(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private now(): string {
|
||||
const now = new Date();
|
||||
return padLeft(now.getUTCHours() + '', 2, '0')
|
||||
+ ':' + padLeft(now.getMinutes() + '', 2, '0')
|
||||
+ ':' + padLeft(now.getUTCSeconds() + '', 2, '0') + '.' + now.getMilliseconds();
|
||||
}
|
||||
|
||||
public updateConfiguration() {
|
||||
this.trace = this.readTrace();
|
||||
}
|
||||
|
||||
private appendLine(value: string) {
|
||||
return this.outputChannel.value.appendLine(value);
|
||||
}
|
||||
|
||||
private readTrace(): Trace {
|
||||
return Trace.fromString(vscode.workspace.getConfiguration().get<string>('markdown.trace', 'off'));
|
||||
}
|
||||
|
||||
private static data2String(data: any): string {
|
||||
if (data instanceof Error) {
|
||||
if (isString(data.stack)) {
|
||||
return data.stack;
|
||||
}
|
||||
return (data as Error).message;
|
||||
}
|
||||
if (isString(data)) {
|
||||
return data;
|
||||
}
|
||||
return JSON.stringify(data, undefined, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function padLeft(s: string, n: number, pad = ' ') {
|
||||
return pad.repeat(Math.max(0, n - s.length)) + s;
|
||||
}
|
@ -0,0 +1,358 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { MarkdownIt, Token } from 'markdown-it';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { MarkdownContributionProvider as MarkdownContributionProvider } from './markdownExtensions';
|
||||
import { Slugifier } from './slugify';
|
||||
import { SkinnyTextDocument } from './tableOfContentsProvider';
|
||||
import { hash } from './util/hash';
|
||||
import { isOfScheme, MarkdownFileExtensions, Schemes } from './util/links';
|
||||
|
||||
const UNICODE_NEWLINE_REGEX = /\u2028|\u2029/g;
|
||||
|
||||
interface MarkdownItConfig {
|
||||
readonly breaks: boolean;
|
||||
readonly linkify: boolean;
|
||||
}
|
||||
|
||||
class TokenCache {
|
||||
private cachedDocument?: {
|
||||
readonly uri: vscode.Uri;
|
||||
readonly version: number;
|
||||
readonly config: MarkdownItConfig;
|
||||
};
|
||||
private tokens?: Token[];
|
||||
|
||||
public tryGetCached(document: SkinnyTextDocument, config: MarkdownItConfig): Token[] | undefined {
|
||||
if (this.cachedDocument
|
||||
&& this.cachedDocument.uri.toString() === document.uri.toString()
|
||||
&& this.cachedDocument.version === document.version
|
||||
&& this.cachedDocument.config.breaks === config.breaks
|
||||
&& this.cachedDocument.config.linkify === config.linkify
|
||||
) {
|
||||
return this.tokens;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public update(document: SkinnyTextDocument, config: MarkdownItConfig, tokens: Token[]) {
|
||||
this.cachedDocument = {
|
||||
uri: document.uri,
|
||||
version: document.version,
|
||||
config,
|
||||
};
|
||||
this.tokens = tokens;
|
||||
}
|
||||
|
||||
public clean(): void {
|
||||
this.cachedDocument = undefined;
|
||||
this.tokens = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class MarkdownEngine {
|
||||
private md?: Promise<MarkdownIt>;
|
||||
|
||||
private currentDocument?: vscode.Uri;
|
||||
private _slugCount = new Map<string, number>();
|
||||
private _tokenCache = new TokenCache();
|
||||
|
||||
public constructor(
|
||||
private readonly contributionProvider: MarkdownContributionProvider,
|
||||
private readonly slugifier: Slugifier,
|
||||
) {
|
||||
contributionProvider.onContributionsChanged(() => {
|
||||
// Markdown plugin contributions may have changed
|
||||
this.md = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async getEngine(config: MarkdownItConfig): Promise<MarkdownIt> {
|
||||
if (!this.md) {
|
||||
this.md = import('markdown-it').then(async markdownIt => {
|
||||
let md: MarkdownIt = markdownIt(await getMarkdownOptions(() => md));
|
||||
|
||||
for (const plugin of this.contributionProvider.contributions.markdownItPlugins.values()) {
|
||||
try {
|
||||
md = (await plugin)(md);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
const frontMatterPlugin = require('markdown-it-front-matter');
|
||||
// Extract rules from front matter plugin and apply at a lower precedence
|
||||
let fontMatterRule: any;
|
||||
frontMatterPlugin({
|
||||
block: {
|
||||
ruler: {
|
||||
before: (_id: any, _id2: any, rule: any) => { fontMatterRule = rule; }
|
||||
}
|
||||
}
|
||||
}, () => { /* noop */ });
|
||||
|
||||
md.block.ruler.before('fence', 'front_matter', fontMatterRule, {
|
||||
alt: ['paragraph', 'reference', 'blockquote', 'list']
|
||||
});
|
||||
|
||||
for (const renderName of ['paragraph_open', 'heading_open', 'image', 'code_block', 'fence', 'blockquote_open', 'list_item_open']) {
|
||||
this.addLineNumberRenderer(md, renderName);
|
||||
}
|
||||
|
||||
this.addImageStabilizer(md);
|
||||
this.addFencedRenderer(md);
|
||||
this.addLinkNormalizer(md);
|
||||
this.addLinkValidator(md);
|
||||
this.addNamedHeaders(md);
|
||||
this.addLinkRenderer(md);
|
||||
return md;
|
||||
});
|
||||
}
|
||||
|
||||
const md = await this.md!;
|
||||
md.set(config);
|
||||
return md;
|
||||
}
|
||||
|
||||
private tokenizeDocument(
|
||||
document: SkinnyTextDocument,
|
||||
config: MarkdownItConfig,
|
||||
engine: MarkdownIt
|
||||
): Token[] {
|
||||
const cached = this._tokenCache.tryGetCached(document, config);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
this.currentDocument = document.uri;
|
||||
|
||||
const tokens = this.tokenizeString(document.getText(), engine);
|
||||
this._tokenCache.update(document, config, tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private tokenizeString(text: string, engine: MarkdownIt) {
|
||||
this._slugCount = new Map<string, number>();
|
||||
|
||||
return engine.parse(text.replace(UNICODE_NEWLINE_REGEX, ''), {});
|
||||
}
|
||||
|
||||
public async render(input: SkinnyTextDocument | string): Promise<string> {
|
||||
const config = this.getConfig(typeof input === 'string' ? undefined : input.uri);
|
||||
const engine = await this.getEngine(config);
|
||||
|
||||
const tokens = typeof input === 'string'
|
||||
? this.tokenizeString(input, engine)
|
||||
: this.tokenizeDocument(input, config, engine);
|
||||
|
||||
return engine.renderer.render(tokens, {
|
||||
...(engine as any).options,
|
||||
...config
|
||||
}, {});
|
||||
}
|
||||
|
||||
public async parse(document: SkinnyTextDocument): Promise<Token[]> {
|
||||
const config = this.getConfig(document.uri);
|
||||
const engine = await this.getEngine(config);
|
||||
return this.tokenizeDocument(document, config, engine);
|
||||
}
|
||||
|
||||
public cleanCache(): void {
|
||||
this._tokenCache.clean();
|
||||
}
|
||||
|
||||
private getConfig(resource?: vscode.Uri): MarkdownItConfig {
|
||||
const config = vscode.workspace.getConfiguration('markdown', resource);
|
||||
return {
|
||||
breaks: config.get<boolean>('preview.breaks', false),
|
||||
linkify: config.get<boolean>('preview.linkify', true)
|
||||
};
|
||||
}
|
||||
|
||||
private addLineNumberRenderer(md: any, ruleName: string): void {
|
||||
const original = md.renderer.rules[ruleName];
|
||||
md.renderer.rules[ruleName] = (tokens: any, idx: number, options: any, env: any, self: any) => {
|
||||
const token = tokens[idx];
|
||||
if (token.map && token.map.length) {
|
||||
token.attrSet('data-line', token.map[0]);
|
||||
token.attrJoin('class', 'code-line');
|
||||
}
|
||||
|
||||
if (original) {
|
||||
return original(tokens, idx, options, env, self);
|
||||
} else {
|
||||
return self.renderToken(tokens, idx, options, env, self);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private addImageStabilizer(md: any): void {
|
||||
const original = md.renderer.rules.image;
|
||||
md.renderer.rules.image = (tokens: any, idx: number, options: any, env: any, self: any) => {
|
||||
const token = tokens[idx];
|
||||
token.attrJoin('class', 'loading');
|
||||
|
||||
const src = token.attrGet('src');
|
||||
if (src) {
|
||||
const imgHash = hash(src);
|
||||
token.attrSet('id', `image-hash-${imgHash}`);
|
||||
}
|
||||
|
||||
if (original) {
|
||||
return original(tokens, idx, options, env, self);
|
||||
} else {
|
||||
return self.renderToken(tokens, idx, options, env, self);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private addFencedRenderer(md: any): void {
|
||||
const original = md.renderer.rules['fenced'];
|
||||
md.renderer.rules['fenced'] = (tokens: any, idx: number, options: any, env: any, self: any) => {
|
||||
const token = tokens[idx];
|
||||
if (token.map && token.map.length) {
|
||||
token.attrJoin('class', 'hljs');
|
||||
}
|
||||
|
||||
return original(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
|
||||
private addLinkNormalizer(md: any): void {
|
||||
const normalizeLink = md.normalizeLink;
|
||||
md.normalizeLink = (link: string) => {
|
||||
try {
|
||||
// Normalize VS Code schemes to target the current version
|
||||
if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {
|
||||
return normalizeLink(vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }).toString());
|
||||
}
|
||||
|
||||
// If original link doesn't look like a url with a scheme, assume it must be a link to a file in workspace
|
||||
if (!/^[a-z\-]+:/i.test(link)) {
|
||||
// Use a fake scheme for parsing
|
||||
let uri = vscode.Uri.parse('markdown-link:' + link);
|
||||
|
||||
// Relative paths should be resolved correctly inside the preview but we need to
|
||||
// handle absolute paths specially (for images) to resolve them relative to the workspace root
|
||||
if (uri.path[0] === '/') {
|
||||
const root = vscode.workspace.getWorkspaceFolder(this.currentDocument!);
|
||||
if (root) {
|
||||
const fileUri = vscode.Uri.joinPath(root.uri, uri.fsPath);
|
||||
uri = fileUri.with({
|
||||
scheme: uri.scheme,
|
||||
fragment: uri.fragment,
|
||||
query: uri.query,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const extname = path.extname(uri.fsPath);
|
||||
|
||||
if (uri.fragment && (extname === '' || MarkdownFileExtensions.includes(extname))) {
|
||||
uri = uri.with({
|
||||
fragment: this.slugifier.fromHeading(uri.fragment).value
|
||||
});
|
||||
}
|
||||
return normalizeLink(uri.toString(true).replace(/^markdown-link:/, ''));
|
||||
}
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
return normalizeLink(link);
|
||||
};
|
||||
}
|
||||
|
||||
private addLinkValidator(md: any): void {
|
||||
const validateLink = md.validateLink;
|
||||
md.validateLink = (link: string) => {
|
||||
// support file:// links
|
||||
return validateLink(link)
|
||||
|| isOfScheme(Schemes.file, link)
|
||||
|| isOfScheme(Schemes.vscode, link)
|
||||
|| isOfScheme(Schemes['vscode-insiders'], link)
|
||||
|| /^data:image\/.*?;/.test(link);
|
||||
};
|
||||
}
|
||||
|
||||
private addNamedHeaders(md: any): void {
|
||||
const original = md.renderer.rules.heading_open;
|
||||
md.renderer.rules.heading_open = (tokens: any, idx: number, options: any, env: any, self: any) => {
|
||||
const title = tokens[idx + 1].children.reduce((acc: string, t: any) => acc + t.content, '');
|
||||
let slug = this.slugifier.fromHeading(title);
|
||||
|
||||
if (this._slugCount.has(slug.value)) {
|
||||
const count = this._slugCount.get(slug.value)!;
|
||||
this._slugCount.set(slug.value, count + 1);
|
||||
slug = this.slugifier.fromHeading(slug.value + '-' + (count + 1));
|
||||
} else {
|
||||
this._slugCount.set(slug.value, 0);
|
||||
}
|
||||
|
||||
tokens[idx].attrs = tokens[idx].attrs || [];
|
||||
tokens[idx].attrs.push(['id', slug.value]);
|
||||
|
||||
if (original) {
|
||||
return original(tokens, idx, options, env, self);
|
||||
} else {
|
||||
return self.renderToken(tokens, idx, options, env, self);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private addLinkRenderer(md: any): void {
|
||||
const old_render = md.renderer.rules.link_open || ((tokens: any, idx: number, options: any, _env: any, self: any) => {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
});
|
||||
|
||||
md.renderer.rules.link_open = (tokens: any, idx: number, options: any, env: any, self: any) => {
|
||||
const token = tokens[idx];
|
||||
const hrefIndex = token.attrIndex('href');
|
||||
if (hrefIndex >= 0) {
|
||||
const href = token.attrs[hrefIndex][1];
|
||||
token.attrPush(['data-href', href]);
|
||||
}
|
||||
return old_render(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getMarkdownOptions(md: () => MarkdownIt) {
|
||||
const hljs = await import('highlight.js');
|
||||
return {
|
||||
html: true,
|
||||
highlight: (str: string, lang?: string) => {
|
||||
lang = normalizeHighlightLang(lang);
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return `<div>${hljs.highlight(lang, str, true).value}</div>`;
|
||||
}
|
||||
catch (error) { }
|
||||
}
|
||||
return `<code><div>${md().utils.escapeHtml(str)}</div></code>`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHighlightLang(lang: string | undefined) {
|
||||
switch (lang && lang.toLowerCase()) {
|
||||
case 'tsx':
|
||||
case 'typescriptreact':
|
||||
// Workaround for highlight not supporting tsx: https://github.com/isagalaev/highlight.js/issues/1155
|
||||
return 'jsx';
|
||||
|
||||
case 'json5':
|
||||
case 'jsonc':
|
||||
return 'json';
|
||||
|
||||
case 'c#':
|
||||
case 'csharp':
|
||||
return 'cs';
|
||||
|
||||
default:
|
||||
return lang;
|
||||
}
|
||||
}
|
@ -0,0 +1,163 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 * as arrays from './util/arrays';
|
||||
import { Disposable } from './util/dispose';
|
||||
|
||||
const resolveExtensionResource = (extension: vscode.Extension<any>, resourcePath: string): vscode.Uri => {
|
||||
return vscode.Uri.joinPath(extension.extensionUri, resourcePath);
|
||||
};
|
||||
|
||||
const resolveExtensionResources = (extension: vscode.Extension<any>, resourcePaths: unknown): vscode.Uri[] => {
|
||||
const result: vscode.Uri[] = [];
|
||||
if (Array.isArray(resourcePaths)) {
|
||||
for (const resource of resourcePaths) {
|
||||
try {
|
||||
result.push(resolveExtensionResource(extension, resource));
|
||||
} catch (e) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export interface MarkdownContributions {
|
||||
readonly previewScripts: ReadonlyArray<vscode.Uri>;
|
||||
readonly previewStyles: ReadonlyArray<vscode.Uri>;
|
||||
readonly previewResourceRoots: ReadonlyArray<vscode.Uri>;
|
||||
readonly markdownItPlugins: Map<string, Thenable<(md: any) => any>>;
|
||||
}
|
||||
|
||||
export namespace MarkdownContributions {
|
||||
export const Empty: MarkdownContributions = {
|
||||
previewScripts: [],
|
||||
previewStyles: [],
|
||||
previewResourceRoots: [],
|
||||
markdownItPlugins: new Map()
|
||||
};
|
||||
|
||||
export function merge(a: MarkdownContributions, b: MarkdownContributions): MarkdownContributions {
|
||||
return {
|
||||
previewScripts: [...a.previewScripts, ...b.previewScripts],
|
||||
previewStyles: [...a.previewStyles, ...b.previewStyles],
|
||||
previewResourceRoots: [...a.previewResourceRoots, ...b.previewResourceRoots],
|
||||
markdownItPlugins: new Map([...a.markdownItPlugins.entries(), ...b.markdownItPlugins.entries()]),
|
||||
};
|
||||
}
|
||||
|
||||
function uriEqual(a: vscode.Uri, b: vscode.Uri): boolean {
|
||||
return a.toString() === b.toString();
|
||||
}
|
||||
|
||||
export function equal(a: MarkdownContributions, b: MarkdownContributions): boolean {
|
||||
return arrays.equals(a.previewScripts, b.previewScripts, uriEqual)
|
||||
&& arrays.equals(a.previewStyles, b.previewStyles, uriEqual)
|
||||
&& arrays.equals(a.previewResourceRoots, b.previewResourceRoots, uriEqual)
|
||||
&& arrays.equals(Array.from(a.markdownItPlugins.keys()), Array.from(b.markdownItPlugins.keys()));
|
||||
}
|
||||
|
||||
export function fromExtension(
|
||||
extension: vscode.Extension<any>
|
||||
): MarkdownContributions {
|
||||
const contributions = extension.packageJSON && extension.packageJSON.contributes;
|
||||
if (!contributions) {
|
||||
return MarkdownContributions.Empty;
|
||||
}
|
||||
|
||||
const previewStyles = getContributedStyles(contributions, extension);
|
||||
const previewScripts = getContributedScripts(contributions, extension);
|
||||
const previewResourceRoots = previewStyles.length || previewScripts.length ? [extension.extensionUri] : [];
|
||||
const markdownItPlugins = getContributedMarkdownItPlugins(contributions, extension);
|
||||
|
||||
return {
|
||||
previewScripts,
|
||||
previewStyles,
|
||||
previewResourceRoots,
|
||||
markdownItPlugins
|
||||
};
|
||||
}
|
||||
|
||||
function getContributedMarkdownItPlugins(
|
||||
contributes: any,
|
||||
extension: vscode.Extension<any>
|
||||
): Map<string, Thenable<(md: any) => any>> {
|
||||
const map = new Map<string, Thenable<(md: any) => any>>();
|
||||
if (contributes['markdown.markdownItPlugins']) {
|
||||
map.set(extension.id, extension.activate().then(() => {
|
||||
if (extension.exports && extension.exports.extendMarkdownIt) {
|
||||
return (md: any) => extension.exports.extendMarkdownIt(md);
|
||||
}
|
||||
return (md: any) => md;
|
||||
}));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getContributedScripts(
|
||||
contributes: any,
|
||||
extension: vscode.Extension<any>
|
||||
) {
|
||||
return resolveExtensionResources(extension, contributes['markdown.previewScripts']);
|
||||
}
|
||||
|
||||
function getContributedStyles(
|
||||
contributes: any,
|
||||
extension: vscode.Extension<any>
|
||||
) {
|
||||
return resolveExtensionResources(extension, contributes['markdown.previewStyles']);
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarkdownContributionProvider {
|
||||
readonly extensionUri: vscode.Uri;
|
||||
|
||||
readonly contributions: MarkdownContributions;
|
||||
readonly onContributionsChanged: vscode.Event<this>;
|
||||
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
class VSCodeExtensionMarkdownContributionProvider extends Disposable implements MarkdownContributionProvider {
|
||||
private _contributions?: MarkdownContributions;
|
||||
|
||||
public constructor(
|
||||
private readonly _extensionContext: vscode.ExtensionContext,
|
||||
) {
|
||||
super();
|
||||
|
||||
vscode.extensions.onDidChange(() => {
|
||||
const currentContributions = this.getCurrentContributions();
|
||||
const existingContributions = this._contributions || MarkdownContributions.Empty;
|
||||
if (!MarkdownContributions.equal(existingContributions, currentContributions)) {
|
||||
this._contributions = currentContributions;
|
||||
this._onContributionsChanged.fire(this);
|
||||
}
|
||||
}, undefined, this._disposables);
|
||||
}
|
||||
|
||||
public get extensionUri() { return this._extensionContext.extensionUri; }
|
||||
|
||||
private readonly _onContributionsChanged = this._register(new vscode.EventEmitter<this>());
|
||||
public readonly onContributionsChanged = this._onContributionsChanged.event;
|
||||
|
||||
public get contributions(): MarkdownContributions {
|
||||
if (!this._contributions) {
|
||||
this._contributions = this.getCurrentContributions();
|
||||
}
|
||||
return this._contributions;
|
||||
}
|
||||
|
||||
private getCurrentContributions(): MarkdownContributions {
|
||||
return vscode.extensions.all
|
||||
.map(MarkdownContributions.fromExtension)
|
||||
.reduce(MarkdownContributions.merge, MarkdownContributions.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMarkdownExtensionContributions(context: vscode.ExtensionContext): MarkdownContributionProvider {
|
||||
return new VSCodeExtensionMarkdownContributionProvider(context);
|
||||
}
|
159
lib/vscode/extensions/markdown-language-features/src/security.ts
Normal file
159
lib/vscode/extensions/markdown-language-features/src/security.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { MarkdownPreviewManager } from './features/previewManager';
|
||||
|
||||
import * as nls from 'vscode-nls';
|
||||
|
||||
const localize = nls.loadMessageBundle();
|
||||
|
||||
export const enum MarkdownPreviewSecurityLevel {
|
||||
Strict = 0,
|
||||
AllowInsecureContent = 1,
|
||||
AllowScriptsAndAllContent = 2,
|
||||
AllowInsecureLocalContent = 3
|
||||
}
|
||||
|
||||
export interface ContentSecurityPolicyArbiter {
|
||||
getSecurityLevelForResource(resource: vscode.Uri): MarkdownPreviewSecurityLevel;
|
||||
|
||||
setSecurityLevelForResource(resource: vscode.Uri, level: MarkdownPreviewSecurityLevel): Thenable<void>;
|
||||
|
||||
shouldAllowSvgsForResource(resource: vscode.Uri): void;
|
||||
|
||||
shouldDisableSecurityWarnings(): boolean;
|
||||
|
||||
setShouldDisableSecurityWarning(shouldShow: boolean): Thenable<void>;
|
||||
}
|
||||
|
||||
export class ExtensionContentSecurityPolicyArbiter implements ContentSecurityPolicyArbiter {
|
||||
private readonly old_trusted_workspace_key = 'trusted_preview_workspace:';
|
||||
private readonly security_level_key = 'preview_security_level:';
|
||||
private readonly should_disable_security_warning_key = 'preview_should_show_security_warning:';
|
||||
|
||||
constructor(
|
||||
private readonly globalState: vscode.Memento,
|
||||
private readonly workspaceState: vscode.Memento
|
||||
) { }
|
||||
|
||||
public getSecurityLevelForResource(resource: vscode.Uri): MarkdownPreviewSecurityLevel {
|
||||
// Use new security level setting first
|
||||
const level = this.globalState.get<MarkdownPreviewSecurityLevel | undefined>(this.security_level_key + this.getRoot(resource), undefined);
|
||||
if (typeof level !== 'undefined') {
|
||||
return level;
|
||||
}
|
||||
|
||||
// Fallback to old trusted workspace setting
|
||||
if (this.globalState.get<boolean>(this.old_trusted_workspace_key + this.getRoot(resource), false)) {
|
||||
return MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent;
|
||||
}
|
||||
return MarkdownPreviewSecurityLevel.Strict;
|
||||
}
|
||||
|
||||
public setSecurityLevelForResource(resource: vscode.Uri, level: MarkdownPreviewSecurityLevel): Thenable<void> {
|
||||
return this.globalState.update(this.security_level_key + this.getRoot(resource), level);
|
||||
}
|
||||
|
||||
public shouldAllowSvgsForResource(resource: vscode.Uri) {
|
||||
const securityLevel = this.getSecurityLevelForResource(resource);
|
||||
return securityLevel === MarkdownPreviewSecurityLevel.AllowInsecureContent || securityLevel === MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent;
|
||||
}
|
||||
|
||||
public shouldDisableSecurityWarnings(): boolean {
|
||||
return this.workspaceState.get<boolean>(this.should_disable_security_warning_key, false);
|
||||
}
|
||||
|
||||
public setShouldDisableSecurityWarning(disabled: boolean): Thenable<void> {
|
||||
return this.workspaceState.update(this.should_disable_security_warning_key, disabled);
|
||||
}
|
||||
|
||||
private getRoot(resource: vscode.Uri): vscode.Uri {
|
||||
if (vscode.workspace.workspaceFolders) {
|
||||
const folderForResource = vscode.workspace.getWorkspaceFolder(resource);
|
||||
if (folderForResource) {
|
||||
return folderForResource.uri;
|
||||
}
|
||||
|
||||
if (vscode.workspace.workspaceFolders.length) {
|
||||
return vscode.workspace.workspaceFolders[0].uri;
|
||||
}
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
|
||||
export class PreviewSecuritySelector {
|
||||
|
||||
public constructor(
|
||||
private readonly cspArbiter: ContentSecurityPolicyArbiter,
|
||||
private readonly webviewManager: MarkdownPreviewManager
|
||||
) { }
|
||||
|
||||
public async showSecuritySelectorForResource(resource: vscode.Uri): Promise<void> {
|
||||
interface PreviewSecurityPickItem extends vscode.QuickPickItem {
|
||||
readonly type: 'moreinfo' | 'toggle' | MarkdownPreviewSecurityLevel;
|
||||
}
|
||||
|
||||
function markActiveWhen(when: boolean): string {
|
||||
return when ? '• ' : '';
|
||||
}
|
||||
|
||||
const currentSecurityLevel = this.cspArbiter.getSecurityLevelForResource(resource);
|
||||
const selection = await vscode.window.showQuickPick<PreviewSecurityPickItem>(
|
||||
[
|
||||
{
|
||||
type: MarkdownPreviewSecurityLevel.Strict,
|
||||
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.Strict) + localize('strict.title', 'Strict'),
|
||||
description: localize('strict.description', 'Only load secure content'),
|
||||
}, {
|
||||
type: MarkdownPreviewSecurityLevel.AllowInsecureLocalContent,
|
||||
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.AllowInsecureLocalContent) + localize('insecureLocalContent.title', 'Allow insecure local content'),
|
||||
description: localize('insecureLocalContent.description', 'Enable loading content over http served from localhost'),
|
||||
}, {
|
||||
type: MarkdownPreviewSecurityLevel.AllowInsecureContent,
|
||||
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.AllowInsecureContent) + localize('insecureContent.title', 'Allow insecure content'),
|
||||
description: localize('insecureContent.description', 'Enable loading content over http'),
|
||||
}, {
|
||||
type: MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent,
|
||||
label: markActiveWhen(currentSecurityLevel === MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent) + localize('disable.title', 'Disable'),
|
||||
description: localize('disable.description', 'Allow all content and script execution. Not recommended'),
|
||||
}, {
|
||||
type: 'moreinfo',
|
||||
label: localize('moreInfo.title', 'More Information'),
|
||||
description: ''
|
||||
}, {
|
||||
type: 'toggle',
|
||||
label: this.cspArbiter.shouldDisableSecurityWarnings()
|
||||
? localize('enableSecurityWarning.title', "Enable preview security warnings in this workspace")
|
||||
: localize('disableSecurityWarning.title', "Disable preview security warning in this workspace"),
|
||||
description: localize('toggleSecurityWarning.description', 'Does not affect the content security level')
|
||||
},
|
||||
], {
|
||||
placeHolder: localize(
|
||||
'preview.showPreviewSecuritySelector.title',
|
||||
'Select security settings for Markdown previews in this workspace'),
|
||||
});
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.type === 'moreinfo') {
|
||||
vscode.commands.executeCommand('vscode.open', vscode.Uri.parse('https://go.microsoft.com/fwlink/?linkid=854414'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.type === 'toggle') {
|
||||
this.cspArbiter.setShouldDisableSecurityWarning(!this.cspArbiter.shouldDisableSecurityWarnings());
|
||||
this.webviewManager.refresh();
|
||||
return;
|
||||
} else {
|
||||
await this.cspArbiter.setSecurityLevelForResource(resource, selection.type);
|
||||
}
|
||||
this.webviewManager.refresh();
|
||||
}
|
||||
}
|
@ -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.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export class Slug {
|
||||
public constructor(
|
||||
public readonly value: string
|
||||
) { }
|
||||
|
||||
public equals(other: Slug): boolean {
|
||||
return this.value === other.value;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Slugifier {
|
||||
fromHeading(heading: string): Slug;
|
||||
}
|
||||
|
||||
export const githubSlugifier: Slugifier = new class implements Slugifier {
|
||||
fromHeading(heading: string): Slug {
|
||||
const slugifiedHeading = encodeURI(
|
||||
heading.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-') // Replace whitespace with -
|
||||
.replace(/[\]\[\!\'\#\$\%\&\(\)\*\+\,\.\/\:\;\<\=\>\?\@\\\^\_\{\|\}\~\`。,、;:?!…—·ˉ¨‘’“”々~‖∶"'`|〃〔〕〈〉《》「」『』.〖〗【】()[]{}]/g, '') // Remove known punctuators
|
||||
.replace(/^\-+/, '') // Remove leading -
|
||||
.replace(/\-+$/, '') // Remove trailing -
|
||||
);
|
||||
return new Slug(slugifiedHeading);
|
||||
}
|
||||
};
|
@ -0,0 +1,118 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { MarkdownEngine } from './markdownEngine';
|
||||
import { Slug, githubSlugifier } from './slugify';
|
||||
|
||||
export interface TocEntry {
|
||||
readonly slug: Slug;
|
||||
readonly text: string;
|
||||
readonly level: number;
|
||||
readonly line: number;
|
||||
readonly location: vscode.Location;
|
||||
}
|
||||
|
||||
export interface SkinnyTextLine {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SkinnyTextDocument {
|
||||
readonly uri: vscode.Uri;
|
||||
readonly version: number;
|
||||
readonly lineCount: number;
|
||||
|
||||
lineAt(line: number): SkinnyTextLine;
|
||||
getText(): string;
|
||||
}
|
||||
|
||||
export class TableOfContentsProvider {
|
||||
private toc?: TocEntry[];
|
||||
|
||||
public constructor(
|
||||
private engine: MarkdownEngine,
|
||||
private document: SkinnyTextDocument
|
||||
) { }
|
||||
|
||||
public async getToc(): Promise<TocEntry[]> {
|
||||
if (!this.toc) {
|
||||
try {
|
||||
this.toc = await this.buildToc(this.document);
|
||||
} catch (e) {
|
||||
this.toc = [];
|
||||
}
|
||||
}
|
||||
return this.toc;
|
||||
}
|
||||
|
||||
public async lookup(fragment: string): Promise<TocEntry | undefined> {
|
||||
const toc = await this.getToc();
|
||||
const slug = githubSlugifier.fromHeading(fragment);
|
||||
return toc.find(entry => entry.slug.equals(slug));
|
||||
}
|
||||
|
||||
private async buildToc(document: SkinnyTextDocument): Promise<TocEntry[]> {
|
||||
const toc: TocEntry[] = [];
|
||||
const tokens = await this.engine.parse(document);
|
||||
|
||||
const existingSlugEntries = new Map<string, { count: number }>();
|
||||
|
||||
for (const heading of tokens.filter(token => token.type === 'heading_open')) {
|
||||
const lineNumber = heading.map[0];
|
||||
const line = document.lineAt(lineNumber);
|
||||
|
||||
let slug = githubSlugifier.fromHeading(line.text);
|
||||
const existingSlugEntry = existingSlugEntries.get(slug.value);
|
||||
if (existingSlugEntry) {
|
||||
++existingSlugEntry.count;
|
||||
slug = githubSlugifier.fromHeading(slug.value + '-' + existingSlugEntry.count);
|
||||
} else {
|
||||
existingSlugEntries.set(slug.value, { count: 0 });
|
||||
}
|
||||
|
||||
toc.push({
|
||||
slug,
|
||||
text: TableOfContentsProvider.getHeaderText(line.text),
|
||||
level: TableOfContentsProvider.getHeaderLevel(heading.markup),
|
||||
line: lineNumber,
|
||||
location: new vscode.Location(document.uri,
|
||||
new vscode.Range(lineNumber, 0, lineNumber, line.text.length))
|
||||
});
|
||||
}
|
||||
|
||||
// Get full range of section
|
||||
return toc.map((entry, startIndex): TocEntry => {
|
||||
let end: number | undefined = undefined;
|
||||
for (let i = startIndex + 1; i < toc.length; ++i) {
|
||||
if (toc[i].level <= entry.level) {
|
||||
end = toc[i].line - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const endLine = end ?? document.lineCount - 1;
|
||||
return {
|
||||
...entry,
|
||||
location: new vscode.Location(document.uri,
|
||||
new vscode.Range(
|
||||
entry.location.range.start,
|
||||
new vscode.Position(endLine, document.lineAt(endLine).text.length)))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static getHeaderLevel(markup: string): number {
|
||||
if (markup === '=') {
|
||||
return 1;
|
||||
} else if (markup === '-') {
|
||||
return 2;
|
||||
} else { // '#', '##', ...
|
||||
return markup.length;
|
||||
}
|
||||
}
|
||||
|
||||
private static getHeaderText(header: string): string {
|
||||
return header.replace(/^\s*#+\s*(.*?)\s*#*$/, (_, word) => word.trim());
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { default as VSCodeTelemetryReporter } from 'vscode-extension-telemetry';
|
||||
|
||||
interface IPackageInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
aiKey: string;
|
||||
}
|
||||
|
||||
export interface TelemetryReporter {
|
||||
dispose(): void;
|
||||
sendTelemetryEvent(eventName: string, properties?: {
|
||||
[key: string]: string;
|
||||
}): void;
|
||||
}
|
||||
|
||||
const nullReporter = new class NullTelemetryReporter implements TelemetryReporter {
|
||||
sendTelemetryEvent() { /** noop */ }
|
||||
dispose() { /** noop */ }
|
||||
};
|
||||
|
||||
class ExtensionReporter implements TelemetryReporter {
|
||||
private readonly _reporter: VSCodeTelemetryReporter;
|
||||
|
||||
constructor(
|
||||
packageInfo: IPackageInfo
|
||||
) {
|
||||
this._reporter = new VSCodeTelemetryReporter(packageInfo.name, packageInfo.version, packageInfo.aiKey);
|
||||
}
|
||||
sendTelemetryEvent(eventName: string, properties?: {
|
||||
[key: string]: string;
|
||||
}) {
|
||||
this._reporter.sendTelemetryEvent(eventName, properties);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._reporter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDefaultTelemetryReporter(): TelemetryReporter {
|
||||
const packageInfo = getPackageInfo();
|
||||
return packageInfo ? new ExtensionReporter(packageInfo) : nullReporter;
|
||||
}
|
||||
|
||||
function getPackageInfo(): IPackageInfo | null {
|
||||
const extension = vscode.extensions.getExtension('Microsoft.vscode-markdown');
|
||||
if (extension && extension.packageJSON) {
|
||||
return {
|
||||
name: extension.packageJSON.name,
|
||||
version: extension.packageJSON.version,
|
||||
aiKey: extension.packageJSON.aiKey
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
@ -0,0 +1,145 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { joinLines } from './util';
|
||||
|
||||
const testFileA = workspaceFile('a.md');
|
||||
|
||||
function workspaceFile(...segments: string[]) {
|
||||
return vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, ...segments);
|
||||
}
|
||||
|
||||
async function getLinksForFile(file: vscode.Uri): Promise<vscode.DocumentLink[]> {
|
||||
return (await vscode.commands.executeCommand<vscode.DocumentLink[]>('vscode.executeLinkProvider', file))!;
|
||||
}
|
||||
|
||||
suite('Markdown Document links', () => {
|
||||
|
||||
teardown(async () => {
|
||||
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
|
||||
});
|
||||
|
||||
test('Should navigate to markdown file', async () => {
|
||||
await withFileContents(testFileA, '[b](b.md)');
|
||||
|
||||
const [link] = await getLinksForFile(testFileA);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(workspaceFile('b.md'));
|
||||
});
|
||||
|
||||
test('Should navigate to markdown file with leading ./', async () => {
|
||||
await withFileContents(testFileA, '[b](./b.md)');
|
||||
|
||||
const [link] = await getLinksForFile(testFileA);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(workspaceFile('b.md'));
|
||||
});
|
||||
|
||||
test('Should navigate to markdown file with leading /', async () => {
|
||||
await withFileContents(testFileA, '[b](./b.md)');
|
||||
|
||||
const [link] = await getLinksForFile(testFileA);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(workspaceFile('b.md'));
|
||||
});
|
||||
|
||||
test('Should navigate to markdown file without file extension', async () => {
|
||||
await withFileContents(testFileA, '[b](b)');
|
||||
|
||||
const [link] = await getLinksForFile(testFileA);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(workspaceFile('b.md'));
|
||||
});
|
||||
|
||||
test('Should navigate to markdown file in directory', async () => {
|
||||
await withFileContents(testFileA, '[b](sub/c)');
|
||||
|
||||
const [link] = await getLinksForFile(testFileA);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(workspaceFile('sub', 'c.md'));
|
||||
});
|
||||
|
||||
test('Should navigate to fragment by title in file', async () => {
|
||||
await withFileContents(testFileA, '[b](sub/c#second)');
|
||||
|
||||
const [link] = await getLinksForFile(testFileA);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(workspaceFile('sub', 'c.md'));
|
||||
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 1);
|
||||
});
|
||||
|
||||
test('Should navigate to fragment by line', async () => {
|
||||
await withFileContents(testFileA, '[b](sub/c#L2)');
|
||||
|
||||
const [link] = await getLinksForFile(testFileA);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(workspaceFile('sub', 'c.md'));
|
||||
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 1);
|
||||
});
|
||||
|
||||
test('Should navigate to fragment within current file', async () => {
|
||||
await withFileContents(testFileA, joinLines(
|
||||
'[](a#header)',
|
||||
'[](#header)',
|
||||
'# Header'));
|
||||
|
||||
const links = await getLinksForFile(testFileA);
|
||||
{
|
||||
await executeLink(links[0]);
|
||||
assertActiveDocumentUri(workspaceFile('a.md'));
|
||||
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 2);
|
||||
}
|
||||
{
|
||||
await executeLink(links[1]);
|
||||
assertActiveDocumentUri(workspaceFile('a.md'));
|
||||
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('Should navigate to fragment within current untitled file', async () => {
|
||||
const testFile = workspaceFile('x.md').with({ scheme: 'untitled' });
|
||||
await withFileContents(testFile, joinLines(
|
||||
'[](#second)',
|
||||
'# Second'));
|
||||
|
||||
const [link] = await getLinksForFile(testFile);
|
||||
await executeLink(link);
|
||||
|
||||
assertActiveDocumentUri(testFile);
|
||||
assert.strictEqual(vscode.window.activeTextEditor!.selection.start.line, 1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function assertActiveDocumentUri(expectedUri: vscode.Uri) {
|
||||
assert.strictEqual(
|
||||
vscode.window.activeTextEditor!.document.uri.fsPath,
|
||||
expectedUri.fsPath
|
||||
);
|
||||
}
|
||||
|
||||
async function withFileContents(file: vscode.Uri, contents: string): Promise<void> {
|
||||
const document = await vscode.workspace.openTextDocument(file);
|
||||
const editor = await vscode.window.showTextDocument(document);
|
||||
await editor.edit(edit => {
|
||||
edit.replace(new vscode.Range(0, 0, 1000, 0), contents);
|
||||
});
|
||||
}
|
||||
|
||||
async function executeLink(link: vscode.DocumentLink) {
|
||||
const args = JSON.parse(decodeURIComponent(link.target!.query));
|
||||
await vscode.commands.executeCommand(link.target!.path, args);
|
||||
}
|
||||
|
@ -0,0 +1,149 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 LinkProvider from '../features/documentLinkProvider';
|
||||
import { InMemoryDocument } from './inMemoryDocument';
|
||||
|
||||
|
||||
const testFile = vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, 'x.md');
|
||||
|
||||
const noopToken = new class implements vscode.CancellationToken {
|
||||
private _onCancellationRequestedEmitter = new vscode.EventEmitter<void>();
|
||||
public onCancellationRequested = this._onCancellationRequestedEmitter.event;
|
||||
|
||||
get isCancellationRequested() { return false; }
|
||||
};
|
||||
|
||||
function getLinksForFile(fileContents: string) {
|
||||
const doc = new InMemoryDocument(testFile, fileContents);
|
||||
const provider = new LinkProvider();
|
||||
return provider.provideDocumentLinks(doc, noopToken);
|
||||
}
|
||||
|
||||
function assertRangeEqual(expected: vscode.Range, actual: vscode.Range) {
|
||||
assert.strictEqual(expected.start.line, actual.start.line);
|
||||
assert.strictEqual(expected.start.character, actual.start.character);
|
||||
assert.strictEqual(expected.end.line, actual.end.line);
|
||||
assert.strictEqual(expected.end.character, actual.end.character);
|
||||
}
|
||||
|
||||
suite('markdown.DocumentLinkProvider', () => {
|
||||
test('Should not return anything for empty document', () => {
|
||||
const links = getLinksForFile('');
|
||||
assert.strictEqual(links.length, 0);
|
||||
});
|
||||
|
||||
test('Should not return anything for simple document without links', () => {
|
||||
const links = getLinksForFile('# a\nfdasfdfsafsa');
|
||||
assert.strictEqual(links.length, 0);
|
||||
});
|
||||
|
||||
test('Should detect basic http links', () => {
|
||||
const links = getLinksForFile('a [b](https://example.com) c');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25));
|
||||
});
|
||||
|
||||
test('Should detect basic workspace links', () => {
|
||||
{
|
||||
const links = getLinksForFile('a [b](./file) c');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 12));
|
||||
}
|
||||
{
|
||||
const links = getLinksForFile('a [b](file.png) c');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 14));
|
||||
}
|
||||
});
|
||||
|
||||
test('Should detect links with title', () => {
|
||||
const links = getLinksForFile('a [b](https://example.com "abc") c');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25));
|
||||
});
|
||||
|
||||
// #35245
|
||||
test('Should handle links with escaped characters in name', () => {
|
||||
const links = getLinksForFile('a [b\\]](./file)');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 8, 0, 14));
|
||||
});
|
||||
|
||||
|
||||
test('Should handle links with balanced parens', () => {
|
||||
{
|
||||
const links = getLinksForFile('a [b](https://example.com/a()c) c');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 30));
|
||||
}
|
||||
{
|
||||
const links = getLinksForFile('a [b](https://example.com/a(b)c) c');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 31));
|
||||
|
||||
}
|
||||
{
|
||||
// #49011
|
||||
const links = getLinksForFile('[A link](http://ThisUrlhasParens/A_link(in_parens))');
|
||||
assert.strictEqual(links.length, 1);
|
||||
const [link] = links;
|
||||
assertRangeEqual(link.range, new vscode.Range(0, 9, 0, 50));
|
||||
}
|
||||
});
|
||||
|
||||
test('Should handle two links without space', () => {
|
||||
const links = getLinksForFile('a ([test](test)[test2](test2)) c');
|
||||
assert.strictEqual(links.length, 2);
|
||||
const [link1, link2] = links;
|
||||
assertRangeEqual(link1.range, new vscode.Range(0, 10, 0, 14));
|
||||
assertRangeEqual(link2.range, new vscode.Range(0, 23, 0, 28));
|
||||
});
|
||||
|
||||
// #49238
|
||||
test('should handle hyperlinked images', () => {
|
||||
{
|
||||
const links = getLinksForFile('[](https://example.com)');
|
||||
assert.strictEqual(links.length, 2);
|
||||
const [link1, link2] = links;
|
||||
assertRangeEqual(link1.range, new vscode.Range(0, 13, 0, 22));
|
||||
assertRangeEqual(link2.range, new vscode.Range(0, 25, 0, 44));
|
||||
}
|
||||
{
|
||||
const links = getLinksForFile('[]( https://whitespace.com )');
|
||||
assert.strictEqual(links.length, 2);
|
||||
const [link1, link2] = links;
|
||||
assertRangeEqual(link1.range, new vscode.Range(0, 7, 0, 21));
|
||||
assertRangeEqual(link2.range, new vscode.Range(0, 26, 0, 48));
|
||||
}
|
||||
{
|
||||
const links = getLinksForFile('[](file1.txt) text [](file2.txt)');
|
||||
assert.strictEqual(links.length, 4);
|
||||
const [link1, link2, link3, link4] = links;
|
||||
assertRangeEqual(link1.range, new vscode.Range(0, 6, 0, 14));
|
||||
assertRangeEqual(link2.range, new vscode.Range(0, 17, 0, 26));
|
||||
assertRangeEqual(link3.range, new vscode.Range(0, 39, 0, 47));
|
||||
assertRangeEqual(link4.range, new vscode.Range(0, 50, 0, 59));
|
||||
}
|
||||
});
|
||||
|
||||
// #107471
|
||||
test('Should not consider link references starting with ^ character valid', () => {
|
||||
const links = getLinksForFile('[^reference]: https://example.com');
|
||||
assert.strictEqual(links.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -0,0 +1,97 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 SymbolProvider from '../features/documentSymbolProvider';
|
||||
import { InMemoryDocument } from './inMemoryDocument';
|
||||
import { createNewMarkdownEngine } from './engine';
|
||||
|
||||
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
|
||||
function getSymbolsForFile(fileContents: string) {
|
||||
const doc = new InMemoryDocument(testFileName, fileContents);
|
||||
const provider = new SymbolProvider(createNewMarkdownEngine());
|
||||
return provider.provideDocumentSymbols(doc);
|
||||
}
|
||||
|
||||
|
||||
suite('markdown.DocumentSymbolProvider', () => {
|
||||
test('Should not return anything for empty document', async () => {
|
||||
const symbols = await getSymbolsForFile('');
|
||||
assert.strictEqual(symbols.length, 0);
|
||||
});
|
||||
|
||||
test('Should not return anything for document with no headers', async () => {
|
||||
const symbols = await getSymbolsForFile('a\na');
|
||||
assert.strictEqual(symbols.length, 0);
|
||||
});
|
||||
|
||||
test('Should not return anything for document with # but no real headers', async () => {
|
||||
const symbols = await getSymbolsForFile('a#a\na#');
|
||||
assert.strictEqual(symbols.length, 0);
|
||||
});
|
||||
|
||||
test('Should return single symbol for single header', async () => {
|
||||
const symbols = await getSymbolsForFile('# h');
|
||||
assert.strictEqual(symbols.length, 1);
|
||||
assert.strictEqual(symbols[0].name, '# h');
|
||||
});
|
||||
|
||||
test('Should not care about symbol level for single header', async () => {
|
||||
const symbols = await getSymbolsForFile('### h');
|
||||
assert.strictEqual(symbols.length, 1);
|
||||
assert.strictEqual(symbols[0].name, '### h');
|
||||
});
|
||||
|
||||
test('Should put symbols of same level in flat list', async () => {
|
||||
const symbols = await getSymbolsForFile('## h\n## h2');
|
||||
assert.strictEqual(symbols.length, 2);
|
||||
assert.strictEqual(symbols[0].name, '## h');
|
||||
assert.strictEqual(symbols[1].name, '## h2');
|
||||
});
|
||||
|
||||
test('Should nest symbol of level - 1 under parent', async () => {
|
||||
|
||||
const symbols = await getSymbolsForFile('# h\n## h2\n## h3');
|
||||
assert.strictEqual(symbols.length, 1);
|
||||
assert.strictEqual(symbols[0].name, '# h');
|
||||
assert.strictEqual(symbols[0].children.length, 2);
|
||||
assert.strictEqual(symbols[0].children[0].name, '## h2');
|
||||
assert.strictEqual(symbols[0].children[1].name, '## h3');
|
||||
});
|
||||
|
||||
test('Should nest symbol of level - n under parent', async () => {
|
||||
const symbols = await getSymbolsForFile('# h\n#### h2');
|
||||
assert.strictEqual(symbols.length, 1);
|
||||
assert.strictEqual(symbols[0].name, '# h');
|
||||
assert.strictEqual(symbols[0].children.length, 1);
|
||||
assert.strictEqual(symbols[0].children[0].name, '#### h2');
|
||||
});
|
||||
|
||||
test('Should flatten children where lower level occurs first', async () => {
|
||||
const symbols = await getSymbolsForFile('# h\n### h2\n## h3');
|
||||
assert.strictEqual(symbols.length, 1);
|
||||
assert.strictEqual(symbols[0].name, '# h');
|
||||
assert.strictEqual(symbols[0].children.length, 2);
|
||||
assert.strictEqual(symbols[0].children[0].name, '### h2');
|
||||
assert.strictEqual(symbols[0].children[1].name, '## h3');
|
||||
});
|
||||
|
||||
test('Should handle line separator in file. Issue #63749', async () => {
|
||||
const symbols = await getSymbolsForFile(`# A
|
||||
- foo
|
||||
|
||||
# B
|
||||
- bar`);
|
||||
assert.strictEqual(symbols.length, 2);
|
||||
assert.strictEqual(symbols[0].name, '# A');
|
||||
assert.strictEqual(symbols[1].name, '# B');
|
||||
});
|
||||
});
|
||||
|
@ -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.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as vscode from 'vscode';
|
||||
import 'mocha';
|
||||
|
||||
import { InMemoryDocument } from './inMemoryDocument';
|
||||
import { createNewMarkdownEngine } from './engine';
|
||||
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
suite('markdown.engine', () => {
|
||||
suite('rendering', () => {
|
||||
const input = '# hello\n\nworld!';
|
||||
const output = '<h1 id="hello" data-line="0" class="code-line">hello</h1>\n'
|
||||
+ '<p data-line="2" class="code-line">world!</p>\n';
|
||||
|
||||
test('Renders a document', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, input);
|
||||
const engine = createNewMarkdownEngine();
|
||||
assert.strictEqual(await engine.render(doc), output);
|
||||
});
|
||||
|
||||
test('Renders a string', async () => {
|
||||
const engine = createNewMarkdownEngine();
|
||||
assert.strictEqual(await engine.render(input), output);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,20 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { MarkdownEngine } from '../markdownEngine';
|
||||
import { MarkdownContributionProvider, MarkdownContributions } from '../markdownExtensions';
|
||||
import { githubSlugifier } from '../slugify';
|
||||
import { Disposable } from '../util/dispose';
|
||||
|
||||
const emptyContributions = new class extends Disposable implements MarkdownContributionProvider {
|
||||
readonly extensionUri = vscode.Uri.file('/');
|
||||
readonly contributions = MarkdownContributions.Empty;
|
||||
readonly onContributionsChanged = this._register(new vscode.EventEmitter<this>()).event;
|
||||
};
|
||||
|
||||
export function createNewMarkdownEngine(): MarkdownEngine {
|
||||
return new MarkdownEngine(emptyContributions, githubSlugifier);
|
||||
}
|
@ -0,0 +1,197 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 * as vscode from 'vscode';
|
||||
import 'mocha';
|
||||
|
||||
import MarkdownFoldingProvider from '../features/foldingProvider';
|
||||
import { InMemoryDocument } from './inMemoryDocument';
|
||||
import { createNewMarkdownEngine } from './engine';
|
||||
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
suite('markdown.FoldingProvider', () => {
|
||||
test('Should not return anything for empty document', async () => {
|
||||
const folds = await getFoldsForDocument(``);
|
||||
assert.strictEqual(folds.length, 0);
|
||||
});
|
||||
|
||||
test('Should not return anything for document without headers', async () => {
|
||||
const folds = await getFoldsForDocument(`a
|
||||
**b** afas
|
||||
a#b
|
||||
a`);
|
||||
assert.strictEqual(folds.length, 0);
|
||||
});
|
||||
|
||||
test('Should fold from header to end of document', async () => {
|
||||
const folds = await getFoldsForDocument(`a
|
||||
# b
|
||||
c
|
||||
d`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 3);
|
||||
});
|
||||
|
||||
test('Should leave single newline before next header', async () => {
|
||||
const folds = await getFoldsForDocument(`
|
||||
# a
|
||||
x
|
||||
|
||||
# b
|
||||
y`);
|
||||
assert.strictEqual(folds.length, 2);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 3);
|
||||
});
|
||||
|
||||
test('Should collapse multuple newlines to single newline before next header', async () => {
|
||||
const folds = await getFoldsForDocument(`
|
||||
# a
|
||||
x
|
||||
|
||||
|
||||
|
||||
# b
|
||||
y`);
|
||||
assert.strictEqual(folds.length, 2);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 5);
|
||||
});
|
||||
|
||||
test('Should not collapse if there is no newline before next header', async () => {
|
||||
const folds = await getFoldsForDocument(`
|
||||
# a
|
||||
x
|
||||
# b
|
||||
y`);
|
||||
assert.strictEqual(folds.length, 2);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 2);
|
||||
});
|
||||
|
||||
test('Should fold nested <!-- #region --> markers', async () => {
|
||||
const folds = await getFoldsForDocument(`a
|
||||
<!-- #region -->
|
||||
b
|
||||
<!-- #region hello!-->
|
||||
b.a
|
||||
<!-- #endregion -->
|
||||
b
|
||||
<!-- #region: foo! -->
|
||||
b.b
|
||||
<!-- #endregion: foo -->
|
||||
b
|
||||
<!-- #endregion -->
|
||||
a`);
|
||||
assert.strictEqual(folds.length, 3);
|
||||
const [outer, first, second] = folds.sort((a, b) => a.start - b.start);
|
||||
|
||||
assert.strictEqual(outer.start, 1);
|
||||
assert.strictEqual(outer.end, 11);
|
||||
assert.strictEqual(first.start, 3);
|
||||
assert.strictEqual(first.end, 5);
|
||||
assert.strictEqual(second.start, 7);
|
||||
assert.strictEqual(second.end, 9);
|
||||
});
|
||||
|
||||
test('Should fold from list to end of document', async () => {
|
||||
const folds = await getFoldsForDocument(`a
|
||||
- b
|
||||
c
|
||||
d`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 3);
|
||||
});
|
||||
|
||||
test('lists folds should span multiple lines of content', async () => {
|
||||
const folds = await getFoldsForDocument(`a
|
||||
- This list item\n spans multiple\n lines.`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 3);
|
||||
});
|
||||
|
||||
test('List should leave single blankline before new element', async () => {
|
||||
const folds = await getFoldsForDocument(`- a
|
||||
a
|
||||
|
||||
|
||||
b`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 0);
|
||||
assert.strictEqual(firstFold.end, 3);
|
||||
});
|
||||
|
||||
test('Should fold fenced code blocks', async () => {
|
||||
const folds = await getFoldsForDocument(`~~~ts
|
||||
a
|
||||
~~~
|
||||
b`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 0);
|
||||
assert.strictEqual(firstFold.end, 2);
|
||||
});
|
||||
|
||||
test('Should fold fenced code blocks with yaml front matter', async () => {
|
||||
const folds = await getFoldsForDocument(`---
|
||||
title: bla
|
||||
---
|
||||
|
||||
~~~ts
|
||||
a
|
||||
~~~
|
||||
|
||||
a
|
||||
a
|
||||
b
|
||||
a`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 4);
|
||||
assert.strictEqual(firstFold.end, 6);
|
||||
});
|
||||
|
||||
test('Should fold html blocks', async () => {
|
||||
const folds = await getFoldsForDocument(`x
|
||||
<div>
|
||||
fa
|
||||
</div>`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 3);
|
||||
});
|
||||
|
||||
test('Should fold html block comments', async () => {
|
||||
const folds = await getFoldsForDocument(`x
|
||||
<!--
|
||||
fa
|
||||
-->`);
|
||||
assert.strictEqual(folds.length, 1);
|
||||
const firstFold = folds[0];
|
||||
assert.strictEqual(firstFold.start, 1);
|
||||
assert.strictEqual(firstFold.end, 3);
|
||||
assert.strictEqual(firstFold.kind, vscode.FoldingRangeKind.Comment);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
async function getFoldsForDocument(contents: string) {
|
||||
const doc = new InMemoryDocument(testFileName, contents);
|
||||
const provider = new MarkdownFoldingProvider(createNewMarkdownEngine());
|
||||
return await provider.provideFoldingRanges(doc, {}, new vscode.CancellationTokenSource().token);
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
|
||||
export class InMemoryDocument implements vscode.TextDocument {
|
||||
private readonly _lines: string[];
|
||||
|
||||
constructor(
|
||||
public readonly uri: vscode.Uri,
|
||||
private readonly _contents: string,
|
||||
public readonly version = 1,
|
||||
) {
|
||||
this._lines = this._contents.split(/\n/g);
|
||||
}
|
||||
|
||||
|
||||
isUntitled: boolean = false;
|
||||
languageId: string = '';
|
||||
isDirty: boolean = false;
|
||||
isClosed: boolean = false;
|
||||
eol: vscode.EndOfLine = vscode.EndOfLine.LF;
|
||||
notebook: undefined;
|
||||
|
||||
get fileName(): string {
|
||||
return this.uri.fsPath;
|
||||
}
|
||||
|
||||
get lineCount(): number {
|
||||
return this._lines.length;
|
||||
}
|
||||
|
||||
lineAt(line: any): vscode.TextLine {
|
||||
return {
|
||||
lineNumber: line,
|
||||
text: this._lines[line],
|
||||
range: new vscode.Range(0, 0, 0, 0),
|
||||
firstNonWhitespaceCharacterIndex: 0,
|
||||
rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 0),
|
||||
isEmptyOrWhitespace: false
|
||||
};
|
||||
}
|
||||
offsetAt(_position: vscode.Position): never {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
positionAt(offset: number): vscode.Position {
|
||||
const before = this._contents.slice(0, offset);
|
||||
const newLines = before.match(/\n/g);
|
||||
const line = newLines ? newLines.length : 0;
|
||||
const preCharacters = before.match(/(\n|^).*$/g);
|
||||
return new vscode.Position(line, preCharacters ? preCharacters[0].length : 0);
|
||||
}
|
||||
getText(_range?: vscode.Range | undefined): string {
|
||||
return this._contents;
|
||||
}
|
||||
getWordRangeAtPosition(_position: vscode.Position, _regex?: RegExp | undefined): never {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
validateRange(_range: vscode.Range): never {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
validatePosition(_position: vscode.Position): never {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
save(): never {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
const path = require('path');
|
||||
const testRunner = require('vscode/lib/testrunner');
|
||||
|
||||
const options: any = {
|
||||
ui: 'tdd',
|
||||
useColors: (!process.env.BUILD_ARTIFACTSTAGINGDIRECTORY && process.platform !== 'win32'),
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
// These integration tests is being run in multiple environments (electron, web, remote)
|
||||
// so we need to set the suite name based on the environment as the suite name is used
|
||||
// for the test results file name
|
||||
let suite = '';
|
||||
if (process.env.VSCODE_BROWSER) {
|
||||
suite = `${process.env.VSCODE_BROWSER} Browser Integration Markdown Tests`;
|
||||
} else if (process.env.REMOTE_VSCODE) {
|
||||
suite = 'Remote Integration Markdown Tests';
|
||||
} else {
|
||||
suite = 'Integration Markdown Tests';
|
||||
}
|
||||
|
||||
if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) {
|
||||
options.reporter = 'mocha-multi-reporters';
|
||||
options.reporterOptions = {
|
||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||
mochaJunitReporterReporterOptions: {
|
||||
testsuitesTitle: `${suite} ${process.platform}`,
|
||||
mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
testRunner.configure(options);
|
||||
|
||||
export = testRunner;
|
@ -0,0 +1,411 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 * as vscode from 'vscode';
|
||||
|
||||
import MarkdownSmartSelect from '../features/smartSelect';
|
||||
import { InMemoryDocument } from './inMemoryDocument';
|
||||
import { createNewMarkdownEngine } from './engine';
|
||||
import { joinLines } from './util';
|
||||
const CURSOR = '$$CURSOR$$';
|
||||
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
suite.only('markdown.SmartSelect', () => {
|
||||
test('Smart select single word', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(`Hel${CURSOR}lo`);
|
||||
assertNestedRangesEqual(ranges![0], [0, 1]);
|
||||
});
|
||||
test('Smart select multi-line paragraph', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`Many of the core components and extensions to ${CURSOR}VS Code live in their own repositories on GitHub. `,
|
||||
`For example, the[node debug adapter](https://github.com/microsoft/vscode-node-debug) and the [mono debug adapter]`,
|
||||
`(https://github.com/microsoft/vscode-mono-debug) have their own repositories. For a complete list, please visit the [Related Projects](https://github.com/microsoft/vscode/wiki/Related-Projects) page on our [wiki](https://github.com/microsoft/vscode/wiki).`
|
||||
));
|
||||
assertNestedRangesEqual(ranges![0], [0, 3]);
|
||||
});
|
||||
test('Smart select paragraph', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(`Many of the core components and extensions to ${CURSOR}VS Code live in their own repositories on GitHub. For example, the [node debug adapter](https://github.com/microsoft/vscode-node-debug) and the [mono debug adapter](https://github.com/microsoft/vscode-mono-debug) have their own repositories. For a complete list, please visit the [Related Projects](https://github.com/microsoft/vscode/wiki/Related-Projects) page on our [wiki](https://github.com/microsoft/vscode/wiki).`);
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [0, 1]);
|
||||
});
|
||||
test('Smart select html block', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`<p align="center">`,
|
||||
`${CURSOR}<img alt="VS Code in action" src="https://user-images.githubusercontent.com/1487073/58344409-70473b80-7e0a-11e9-8570-b2efc6f8fa44.png">`,
|
||||
`</p>`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [0, 3]);
|
||||
});
|
||||
test('Smart select header on header line', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# Header${CURSOR}`,
|
||||
`Hello`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [0, 1]);
|
||||
|
||||
});
|
||||
test('Smart select single word w grandparent header on text line', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`## ParentHeader`,
|
||||
`# Header`,
|
||||
`${CURSOR}Hello`
|
||||
));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [2, 2], [1, 2]);
|
||||
});
|
||||
test('Smart select html block w parent header', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# Header`,
|
||||
`${CURSOR}<p align="center">`,
|
||||
`<img alt="VS Code in action" src="https://user-images.githubusercontent.com/1487073/58344409-70473b80-7e0a-11e9-8570-b2efc6f8fa44.png">`,
|
||||
`</p>`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [1, 3], [1, 3], [0, 3]);
|
||||
});
|
||||
test('Smart select fenced code block', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`~~~`,
|
||||
`a${CURSOR}`,
|
||||
`~~~`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [0, 2]);
|
||||
});
|
||||
test('Smart select list', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`- item 1`,
|
||||
`- ${CURSOR}item 2`,
|
||||
`- item 3`,
|
||||
`- item 4`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [1, 1], [0, 3]);
|
||||
});
|
||||
test('Smart select list with fenced code block', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`- item 1`,
|
||||
`- ~~~`,
|
||||
` ${CURSOR}a`,
|
||||
` ~~~`,
|
||||
`- item 3`,
|
||||
`- item 4`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [1, 3], [0, 5]);
|
||||
});
|
||||
test('Smart select multi cursor', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`- ${CURSOR}item 1`,
|
||||
`- ~~~`,
|
||||
` a`,
|
||||
` ~~~`,
|
||||
`- ${CURSOR}item 3`,
|
||||
`- item 4`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [0, 0], [0, 5]);
|
||||
assertNestedRangesEqual(ranges![1], [4, 4], [0, 5]);
|
||||
});
|
||||
test('Smart select nested block quotes', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`> item 1`,
|
||||
`> item 2`,
|
||||
`>> ${CURSOR}item 3`,
|
||||
`>> item 4`));
|
||||
assertNestedRangesEqual(ranges![0], [2, 4], [0, 4]);
|
||||
});
|
||||
test('Smart select multi nested block quotes', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`> item 1`,
|
||||
`>> item 2`,
|
||||
`>>> ${CURSOR}item 3`,
|
||||
`>>>> item 4`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [2, 3], [2, 4], [1, 4], [0, 4]);
|
||||
});
|
||||
test('Smart select subheader content', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
`content 1`,
|
||||
`## sub header 1`,
|
||||
`${CURSOR}content 2`,
|
||||
`# main header 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [3, 3], [2, 3], [1, 3], [0, 3]);
|
||||
});
|
||||
test('Smart select subheader line', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
`content 1`,
|
||||
`## sub header 1${CURSOR}`,
|
||||
`content 2`,
|
||||
`# main header 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [2, 3], [1, 3], [0, 3]);
|
||||
});
|
||||
test('Smart select blank line', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
`content 1`,
|
||||
`${CURSOR} `,
|
||||
`content 2`,
|
||||
`# main header 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [1, 3], [0, 3]);
|
||||
});
|
||||
test('Smart select line between paragraphs', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`paragraph 1`,
|
||||
`${CURSOR}`,
|
||||
`paragraph 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [0, 3]);
|
||||
});
|
||||
test('Smart select empty document', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(``, [new vscode.Position(0, 0)]);
|
||||
assert.strictEqual(ranges!.length, 0);
|
||||
});
|
||||
test('Smart select fenced code block then list then subheader content then subheader then header content then header', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
`content 1`,
|
||||
`## sub header 1`,
|
||||
`- item 1`,
|
||||
`- ~~~`,
|
||||
` ${CURSOR}a`,
|
||||
` ~~~`,
|
||||
`- item 3`,
|
||||
`- item 4`,
|
||||
``,
|
||||
`more content`,
|
||||
`# main header 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [4, 6], [3, 9], [3, 10], [2, 10], [1, 10], [0, 10]);
|
||||
});
|
||||
test('Smart select list with one element without selecting child subheader', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
``,
|
||||
`- list ${CURSOR}`,
|
||||
``,
|
||||
`## sub header`,
|
||||
``,
|
||||
`content 2`,
|
||||
`# main header 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [2, 3], [1, 3], [1, 6], [0, 6]);
|
||||
});
|
||||
test('Smart select content under header then subheaders and their content', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main ${CURSOR}header 1`,
|
||||
``,
|
||||
`- list`,
|
||||
`paragraph`,
|
||||
`## sub header`,
|
||||
``,
|
||||
`content 2`,
|
||||
`# main header 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [0, 3], [0, 6]);
|
||||
});
|
||||
test('Smart select last blockquote element under header then subheaders and their content', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
``,
|
||||
`> block`,
|
||||
`> block`,
|
||||
`>> block`,
|
||||
`>> ${CURSOR}block`,
|
||||
``,
|
||||
`paragraph`,
|
||||
`## sub header`,
|
||||
``,
|
||||
`content 2`,
|
||||
`# main header 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [4, 6], [2, 6], [1, 7], [1, 10], [0, 10]);
|
||||
});
|
||||
test('Smart select content of subheader then subheader then content of main header then main header', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
``,
|
||||
`> block`,
|
||||
`> block`,
|
||||
`>> block`,
|
||||
`>> block`,
|
||||
``,
|
||||
`paragraph`,
|
||||
`## sub header`,
|
||||
``,
|
||||
``,
|
||||
`${CURSOR}`,
|
||||
``,
|
||||
`### main header 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`content 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [11, 12], [9, 12], [9, 17], [8, 17], [1, 17], [0, 17]);
|
||||
});
|
||||
test('Smart select last line content of subheader then subheader then content of main header then main header', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
``,
|
||||
`> block`,
|
||||
`> block`,
|
||||
`>> block`,
|
||||
`>> block`,
|
||||
``,
|
||||
`paragraph`,
|
||||
`## sub header`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
`### main header 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`${CURSOR}content 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [16, 17], [14, 17], [14, 17], [13, 17], [9, 17], [8, 17], [1, 17], [0, 17]);
|
||||
});
|
||||
test('Smart select last line content after content of subheader then subheader then content of main header then main header', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
``,
|
||||
`> block`,
|
||||
`> block`,
|
||||
`>> block`,
|
||||
`>> block`,
|
||||
``,
|
||||
`paragraph`,
|
||||
`## sub header`,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
``,
|
||||
`### main header 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`content 2${CURSOR}`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [16, 17], [14, 17], [14, 17], [13, 17], [9, 17], [8, 17], [1, 17], [0, 17]);
|
||||
});
|
||||
test('Smart select fenced code block then list then rest of content', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
``,
|
||||
`> block`,
|
||||
`> block`,
|
||||
`>> block`,
|
||||
`>> block`,
|
||||
``,
|
||||
`- paragraph`,
|
||||
`- ~~~`,
|
||||
` my`,
|
||||
` ${CURSOR}code`,
|
||||
` goes here`,
|
||||
` ~~~`,
|
||||
`- content`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`- content 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [9, 11], [8, 12], [7, 17], [1, 17], [0, 17]);
|
||||
});
|
||||
test('Smart select fenced code block then list then rest of content on fenced line', async () => {
|
||||
const ranges = await getSelectionRangesForDocument(
|
||||
joinLines(
|
||||
`# main header 1`,
|
||||
``,
|
||||
`> block`,
|
||||
`> block`,
|
||||
`>> block`,
|
||||
`>> block`,
|
||||
``,
|
||||
`- paragraph`,
|
||||
`- ~~~${CURSOR}`,
|
||||
` my`,
|
||||
` code`,
|
||||
` goes here`,
|
||||
` ~~~`,
|
||||
`- content`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`- content 2`,
|
||||
`- content 2`));
|
||||
|
||||
assertNestedRangesEqual(ranges![0], [8, 12], [7, 17], [1, 17], [0, 17]);
|
||||
});
|
||||
});
|
||||
|
||||
function assertNestedRangesEqual(range: vscode.SelectionRange, ...expectedRanges: [number, number][]) {
|
||||
const lineage = getLineage(range);
|
||||
assert.strictEqual(lineage.length, expectedRanges.length, `expected depth: ${expectedRanges.length}, but was ${lineage.length}`);
|
||||
for (let i = 0; i < lineage.length; i++) {
|
||||
assertRangesEqual(lineage[i], expectedRanges[i][0], expectedRanges[i][1], `parent at a depth of ${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getLineage(range: vscode.SelectionRange): vscode.SelectionRange[] {
|
||||
const result: vscode.SelectionRange[] = [];
|
||||
let currentRange: vscode.SelectionRange | undefined = range;
|
||||
while (currentRange) {
|
||||
result.push(currentRange);
|
||||
currentRange = currentRange.parent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertRangesEqual(selectionRange: vscode.SelectionRange, startLine: number, endLine: number, message: string) {
|
||||
assert.strictEqual(selectionRange.range.start.line, startLine, `failed on start line ${message}`);
|
||||
assert.strictEqual(selectionRange.range.end.line, endLine, `failed on end line ${message}`);
|
||||
}
|
||||
|
||||
async function getSelectionRangesForDocument(contents: string, pos?: vscode.Position[]) {
|
||||
const doc = new InMemoryDocument(testFileName, contents);
|
||||
const provider = new MarkdownSmartSelect(createNewMarkdownEngine());
|
||||
const positions = pos ? pos : getCursorPositions(contents, doc);
|
||||
return await provider.provideSelectionRanges(doc, positions, new vscode.CancellationTokenSource().token);
|
||||
}
|
||||
|
||||
let getCursorPositions = (contents: string, doc: InMemoryDocument): vscode.Position[] => {
|
||||
let positions: vscode.Position[] = [];
|
||||
let index = 0;
|
||||
let wordLength = 0;
|
||||
while (index !== -1) {
|
||||
index = contents.indexOf(CURSOR, index + wordLength);
|
||||
if (index !== -1) {
|
||||
positions.push(doc.positionAt(index));
|
||||
}
|
||||
wordLength = CURSOR.length;
|
||||
}
|
||||
return positions;
|
||||
};
|
@ -0,0 +1,130 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 * as vscode from 'vscode';
|
||||
import 'mocha';
|
||||
|
||||
import { TableOfContentsProvider } from '../tableOfContentsProvider';
|
||||
import { InMemoryDocument } from './inMemoryDocument';
|
||||
import { createNewMarkdownEngine } from './engine';
|
||||
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
suite('markdown.TableOfContentsProvider', () => {
|
||||
test('Lookup should not return anything for empty document', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, '');
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
assert.strictEqual(await provider.lookup(''), undefined);
|
||||
assert.strictEqual(await provider.lookup('foo'), undefined);
|
||||
});
|
||||
|
||||
test('Lookup should not return anything for document with no headers', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, 'a *b*\nc');
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
assert.strictEqual(await provider.lookup(''), undefined);
|
||||
assert.strictEqual(await provider.lookup('foo'), undefined);
|
||||
assert.strictEqual(await provider.lookup('a'), undefined);
|
||||
assert.strictEqual(await provider.lookup('b'), undefined);
|
||||
});
|
||||
|
||||
test('Lookup should return basic #header', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, `# a\nx\n# c`);
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
{
|
||||
const entry = await provider.lookup('a');
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry!.line, 0);
|
||||
}
|
||||
{
|
||||
assert.strictEqual(await provider.lookup('x'), undefined);
|
||||
}
|
||||
{
|
||||
const entry = await provider.lookup('c');
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry!.line, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('Lookups should be case in-sensitive', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, `# fOo\n`);
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
assert.strictEqual((await provider.lookup('fOo'))!.line, 0);
|
||||
assert.strictEqual((await provider.lookup('foo'))!.line, 0);
|
||||
assert.strictEqual((await provider.lookup('FOO'))!.line, 0);
|
||||
});
|
||||
|
||||
test('Lookups should ignore leading and trailing white-space, and collapse internal whitespace', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, `# f o o \n`);
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
assert.strictEqual((await provider.lookup('f o o'))!.line, 0);
|
||||
assert.strictEqual((await provider.lookup(' f o o'))!.line, 0);
|
||||
assert.strictEqual((await provider.lookup(' f o o '))!.line, 0);
|
||||
assert.strictEqual((await provider.lookup('f o o'))!.line, 0);
|
||||
assert.strictEqual((await provider.lookup('f o o'))!.line, 0);
|
||||
|
||||
assert.strictEqual(await provider.lookup('f'), undefined);
|
||||
assert.strictEqual(await provider.lookup('foo'), undefined);
|
||||
assert.strictEqual(await provider.lookup('fo o'), undefined);
|
||||
});
|
||||
|
||||
test('should handle special characters #44779', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, `# Indentação\n`);
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
assert.strictEqual((await provider.lookup('indentação'))!.line, 0);
|
||||
});
|
||||
|
||||
test('should handle special characters 2, #48482', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, `# Инструкция - Делай Раз, Делай Два\n`);
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
assert.strictEqual((await provider.lookup('инструкция---делай-раз-делай-два'))!.line, 0);
|
||||
});
|
||||
|
||||
test('should handle special characters 3, #37079', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, `## Header 2
|
||||
### Header 3
|
||||
## Заголовок 2
|
||||
### Заголовок 3
|
||||
### Заголовок Header 3
|
||||
## Заголовок`);
|
||||
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
assert.strictEqual((await provider.lookup('header-2'))!.line, 0);
|
||||
assert.strictEqual((await provider.lookup('header-3'))!.line, 1);
|
||||
assert.strictEqual((await provider.lookup('Заголовок-2'))!.line, 2);
|
||||
assert.strictEqual((await provider.lookup('Заголовок-3'))!.line, 3);
|
||||
assert.strictEqual((await provider.lookup('Заголовок-header-3'))!.line, 4);
|
||||
assert.strictEqual((await provider.lookup('Заголовок'))!.line, 5);
|
||||
});
|
||||
|
||||
test('Lookup should support suffixes for repeated headers', async () => {
|
||||
const doc = new InMemoryDocument(testFileName, `# a\n# a\n## a`);
|
||||
const provider = new TableOfContentsProvider(createNewMarkdownEngine(), doc);
|
||||
|
||||
{
|
||||
const entry = await provider.lookup('a');
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry!.line, 0);
|
||||
}
|
||||
{
|
||||
const entry = await provider.lookup('a-1');
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry!.line, 1);
|
||||
}
|
||||
{
|
||||
const entry = await provider.lookup('a-2');
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry!.line, 2);
|
||||
}
|
||||
});
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
import * as os from 'os';
|
||||
|
||||
export const joinLines = (...args: string[]) =>
|
||||
args.join(os.platform() === 'win32' ? '\r\n' : '\n');
|
@ -0,0 +1,142 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 MDDocumentSymbolProvider from '../features/documentSymbolProvider';
|
||||
import MarkdownWorkspaceSymbolProvider, { WorkspaceMarkdownDocumentProvider } from '../features/workspaceSymbolProvider';
|
||||
import { createNewMarkdownEngine } from './engine';
|
||||
import { InMemoryDocument } from './inMemoryDocument';
|
||||
|
||||
|
||||
const symbolProvider = new MDDocumentSymbolProvider(createNewMarkdownEngine());
|
||||
|
||||
suite('markdown.WorkspaceSymbolProvider', () => {
|
||||
test('Should not return anything for empty workspace', async () => {
|
||||
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, new InMemoryWorkspaceMarkdownDocumentProvider([]));
|
||||
|
||||
assert.deepEqual(await provider.provideWorkspaceSymbols(''), []);
|
||||
});
|
||||
|
||||
test('Should return symbols from workspace with one markdown file', async () => {
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, new InMemoryWorkspaceMarkdownDocumentProvider([
|
||||
new InMemoryDocument(testFileName, `# header1\nabc\n## header2`)
|
||||
]));
|
||||
|
||||
const symbols = await provider.provideWorkspaceSymbols('');
|
||||
assert.strictEqual(symbols.length, 2);
|
||||
assert.strictEqual(symbols[0].name, '# header1');
|
||||
assert.strictEqual(symbols[1].name, '## header2');
|
||||
});
|
||||
|
||||
test('Should return all content basic workspace', async () => {
|
||||
const fileNameCount = 10;
|
||||
const files: vscode.TextDocument[] = [];
|
||||
for (let i = 0; i < fileNameCount; ++i) {
|
||||
const testFileName = vscode.Uri.file(`test${i}.md`);
|
||||
files.push(new InMemoryDocument(testFileName, `# common\nabc\n## header${i}`));
|
||||
}
|
||||
|
||||
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, new InMemoryWorkspaceMarkdownDocumentProvider(files));
|
||||
|
||||
const symbols = await provider.provideWorkspaceSymbols('');
|
||||
assert.strictEqual(symbols.length, fileNameCount * 2);
|
||||
});
|
||||
|
||||
test('Should update results when markdown file changes symbols', async () => {
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
const workspaceFileProvider = new InMemoryWorkspaceMarkdownDocumentProvider([
|
||||
new InMemoryDocument(testFileName, `# header1`, 1 /* version */)
|
||||
]);
|
||||
|
||||
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, workspaceFileProvider);
|
||||
|
||||
assert.strictEqual((await provider.provideWorkspaceSymbols('')).length, 1);
|
||||
|
||||
// Update file
|
||||
workspaceFileProvider.updateDocument(new InMemoryDocument(testFileName, `# new header\nabc\n## header2`, 2 /* version */));
|
||||
const newSymbols = await provider.provideWorkspaceSymbols('');
|
||||
assert.strictEqual(newSymbols.length, 2);
|
||||
assert.strictEqual(newSymbols[0].name, '# new header');
|
||||
assert.strictEqual(newSymbols[1].name, '## header2');
|
||||
});
|
||||
|
||||
test('Should remove results when file is deleted', async () => {
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
const workspaceFileProvider = new InMemoryWorkspaceMarkdownDocumentProvider([
|
||||
new InMemoryDocument(testFileName, `# header1`)
|
||||
]);
|
||||
|
||||
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, workspaceFileProvider);
|
||||
assert.strictEqual((await provider.provideWorkspaceSymbols('')).length, 1);
|
||||
|
||||
// delete file
|
||||
workspaceFileProvider.deleteDocument(testFileName);
|
||||
const newSymbols = await provider.provideWorkspaceSymbols('');
|
||||
assert.strictEqual(newSymbols.length, 0);
|
||||
});
|
||||
|
||||
test('Should update results when markdown file is created', async () => {
|
||||
const testFileName = vscode.Uri.file('test.md');
|
||||
|
||||
const workspaceFileProvider = new InMemoryWorkspaceMarkdownDocumentProvider([
|
||||
new InMemoryDocument(testFileName, `# header1`)
|
||||
]);
|
||||
|
||||
const provider = new MarkdownWorkspaceSymbolProvider(symbolProvider, workspaceFileProvider);
|
||||
assert.strictEqual((await provider.provideWorkspaceSymbols('')).length, 1);
|
||||
|
||||
// Creat file
|
||||
workspaceFileProvider.createDocument(new InMemoryDocument(vscode.Uri.file('test2.md'), `# new header\nabc\n## header2`));
|
||||
const newSymbols = await provider.provideWorkspaceSymbols('');
|
||||
assert.strictEqual(newSymbols.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
class InMemoryWorkspaceMarkdownDocumentProvider implements WorkspaceMarkdownDocumentProvider {
|
||||
private readonly _documents = new Map<string, vscode.TextDocument>();
|
||||
|
||||
constructor(documents: vscode.TextDocument[]) {
|
||||
for (const doc of documents) {
|
||||
this._documents.set(doc.fileName, doc);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMarkdownDocuments() {
|
||||
return Array.from(this._documents.values());
|
||||
}
|
||||
|
||||
private readonly _onDidChangeMarkdownDocumentEmitter = new vscode.EventEmitter<vscode.TextDocument>();
|
||||
public onDidChangeMarkdownDocument = this._onDidChangeMarkdownDocumentEmitter.event;
|
||||
|
||||
private readonly _onDidCreateMarkdownDocumentEmitter = new vscode.EventEmitter<vscode.TextDocument>();
|
||||
public onDidCreateMarkdownDocument = this._onDidCreateMarkdownDocumentEmitter.event;
|
||||
|
||||
private readonly _onDidDeleteMarkdownDocumentEmitter = new vscode.EventEmitter<vscode.Uri>();
|
||||
public onDidDeleteMarkdownDocument = this._onDidDeleteMarkdownDocumentEmitter.event;
|
||||
|
||||
public updateDocument(document: vscode.TextDocument) {
|
||||
this._documents.set(document.fileName, document);
|
||||
this._onDidChangeMarkdownDocumentEmitter.fire(document);
|
||||
}
|
||||
|
||||
public createDocument(document: vscode.TextDocument) {
|
||||
assert.ok(!this._documents.has(document.uri.fsPath));
|
||||
|
||||
this._documents.set(document.uri.fsPath, document);
|
||||
this._onDidCreateMarkdownDocumentEmitter.fire(document);
|
||||
}
|
||||
|
||||
public deleteDocument(resource: vscode.Uri) {
|
||||
this._documents.delete(resource.fsPath);
|
||||
this._onDidDeleteMarkdownDocumentEmitter.fire(resource);
|
||||
}
|
||||
}
|
8
lib/vscode/extensions/markdown-language-features/src/typings/ref.d.ts
vendored
Normal file
8
lib/vscode/extensions/markdown-language-features/src/typings/ref.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/// <reference path='../../../../src/vs/vscode.d.ts'/>
|
||||
/// <reference path='../../../../src/vs/vscode.proposed.d.ts'/>
|
||||
/// <reference types='@types/node'/>
|
@ -0,0 +1,22 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export function equals<T>(one: ReadonlyArray<T>, other: ReadonlyArray<T>, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean {
|
||||
if (one.length !== other.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0, len = one.length; i < len; i++) {
|
||||
if (!itemEquals(one[i], other[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function flatten<T>(arr: ReadonlyArray<T>[]): T[] {
|
||||
return ([] as T[]).concat.apply([], arr);
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
|
||||
export function disposeAll(disposables: vscode.Disposable[]) {
|
||||
while (disposables.length) {
|
||||
const item = disposables.pop();
|
||||
if (item) {
|
||||
item.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Disposable {
|
||||
private _isDisposed = false;
|
||||
|
||||
protected _disposables: vscode.Disposable[] = [];
|
||||
|
||||
public dispose(): any {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._isDisposed = true;
|
||||
disposeAll(this._disposables);
|
||||
}
|
||||
|
||||
protected _register<T extends vscode.Disposable>(value: T): T {
|
||||
if (this._isDisposed) {
|
||||
value.dispose();
|
||||
} else {
|
||||
this._disposables.push(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
protected get isDisposed() {
|
||||
return this._isDisposed;
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
|
||||
export function isMarkdownFile(document: vscode.TextDocument) {
|
||||
return document.languageId === 'markdown';
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Return a hash value for an object.
|
||||
*/
|
||||
export function hash(obj: any, hashVal = 0): number {
|
||||
switch (typeof obj) {
|
||||
case 'object':
|
||||
if (obj === null) {
|
||||
return numberHash(349, hashVal);
|
||||
} else if (Array.isArray(obj)) {
|
||||
return arrayHash(obj, hashVal);
|
||||
}
|
||||
return objectHash(obj, hashVal);
|
||||
case 'string':
|
||||
return stringHash(obj, hashVal);
|
||||
case 'boolean':
|
||||
return booleanHash(obj, hashVal);
|
||||
case 'number':
|
||||
return numberHash(obj, hashVal);
|
||||
case 'undefined':
|
||||
return 937 * 31;
|
||||
default:
|
||||
return numberHash(obj, 617);
|
||||
}
|
||||
}
|
||||
|
||||
function numberHash(val: number, initialHashVal: number): number {
|
||||
return (((initialHashVal << 5) - initialHashVal) + val) | 0; // hashVal * 31 + ch, keep as int32
|
||||
}
|
||||
|
||||
function booleanHash(b: boolean, initialHashVal: number): number {
|
||||
return numberHash(b ? 433 : 863, initialHashVal);
|
||||
}
|
||||
|
||||
function stringHash(s: string, hashVal: number) {
|
||||
hashVal = numberHash(149417, hashVal);
|
||||
for (let i = 0, length = s.length; i < length; i++) {
|
||||
hashVal = numberHash(s.charCodeAt(i), hashVal);
|
||||
}
|
||||
return hashVal;
|
||||
}
|
||||
|
||||
function arrayHash(arr: any[], initialHashVal: number): number {
|
||||
initialHashVal = numberHash(104579, initialHashVal);
|
||||
return arr.reduce((hashVal, item) => hash(item, hashVal), initialHashVal);
|
||||
}
|
||||
|
||||
function objectHash(obj: any, initialHashVal: number): number {
|
||||
initialHashVal = numberHash(181387, initialHashVal);
|
||||
return Object.keys(obj).sort().reduce((hashVal, key) => {
|
||||
hashVal = stringHash(key, hashVal);
|
||||
return hash(obj[key], hashVal);
|
||||
}, initialHashVal);
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export interface Lazy<T> {
|
||||
readonly value: T;
|
||||
readonly hasValue: boolean;
|
||||
map<R>(f: (x: T) => R): Lazy<R>;
|
||||
}
|
||||
|
||||
class LazyValue<T> implements Lazy<T> {
|
||||
private _hasValue: boolean = false;
|
||||
private _value?: T;
|
||||
|
||||
constructor(
|
||||
private readonly _getValue: () => T
|
||||
) { }
|
||||
|
||||
get value(): T {
|
||||
if (!this._hasValue) {
|
||||
this._hasValue = true;
|
||||
this._value = this._getValue();
|
||||
}
|
||||
return this._value!;
|
||||
}
|
||||
|
||||
get hasValue(): boolean {
|
||||
return this._hasValue;
|
||||
}
|
||||
|
||||
public map<R>(f: (x: T) => R): Lazy<R> {
|
||||
return new LazyValue(() => f(this.value));
|
||||
}
|
||||
}
|
||||
|
||||
export function lazy<T>(getValue: () => T): Lazy<T> {
|
||||
return new LazyValue<T>(getValue);
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
|
||||
export const Schemes = {
|
||||
http: 'http:',
|
||||
https: 'https:',
|
||||
file: 'file:',
|
||||
untitled: 'untitled',
|
||||
mailto: 'mailto:',
|
||||
data: 'data:',
|
||||
vscode: 'vscode:',
|
||||
'vscode-insiders': 'vscode-insiders:',
|
||||
'vscode-resource': 'vscode-resource:',
|
||||
};
|
||||
|
||||
const knownSchemes = [
|
||||
...Object.values(Schemes),
|
||||
`${vscode.env.uriScheme}:`
|
||||
];
|
||||
|
||||
export function getUriForLinkWithKnownExternalScheme(link: string): vscode.Uri | undefined {
|
||||
if (knownSchemes.some(knownScheme => isOfScheme(knownScheme, link))) {
|
||||
return vscode.Uri.parse(link);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isOfScheme(scheme: string, link: string): boolean {
|
||||
return link.toLowerCase().startsWith(scheme);
|
||||
}
|
||||
|
||||
export const MarkdownFileExtensions: readonly string[] = [
|
||||
'.md',
|
||||
'.mkd',
|
||||
'.mdwn',
|
||||
'.mdown',
|
||||
'.markdown',
|
||||
'.markdn',
|
||||
'.mdtxt',
|
||||
'.mdtext',
|
||||
'.workbook',
|
||||
];
|
@ -0,0 +1,33 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
|
||||
export interface WebviewResourceProvider {
|
||||
asWebviewUri(resource: vscode.Uri): vscode.Uri;
|
||||
|
||||
readonly cspSource: string;
|
||||
}
|
||||
|
||||
export function normalizeResource(
|
||||
base: vscode.Uri,
|
||||
resource: vscode.Uri
|
||||
): vscode.Uri {
|
||||
// If we have a windows path and are loading a workspace with an authority,
|
||||
// make sure we use a unc path with an explicit localhost authority.
|
||||
//
|
||||
// Otherwise, the `<base>` rule will insert the authority into the resolved resource
|
||||
// URI incorrectly.
|
||||
if (base.authority && !resource.authority) {
|
||||
const driveMatch = resource.path.match(/^\/(\w):\//);
|
||||
if (driveMatch) {
|
||||
return vscode.Uri.file(`\\\\localhost\\${driveMatch[1]}$\\${resource.fsPath.replace(/^\w:\\/, '')}`).with({
|
||||
fragment: resource.fragment,
|
||||
query: resource.query
|
||||
});
|
||||
}
|
||||
}
|
||||
return resource;
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { Disposable } from '../util/dispose';
|
||||
import { isMarkdownFile } from './file';
|
||||
|
||||
export class TopmostLineMonitor extends Disposable {
|
||||
|
||||
private readonly pendingUpdates = new Map<string, number>();
|
||||
private readonly throttle = 50;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._register(vscode.window.onDidChangeTextEditorVisibleRanges(event => {
|
||||
if (isMarkdownFile(event.textEditor.document)) {
|
||||
const line = getVisibleLine(event.textEditor);
|
||||
if (typeof line === 'number') {
|
||||
this.updateLine(event.textEditor.document.uri, line);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private readonly _onChanged = this._register(new vscode.EventEmitter<{ readonly resource: vscode.Uri, readonly line: number }>());
|
||||
public readonly onDidChanged = this._onChanged.event;
|
||||
|
||||
private updateLine(
|
||||
resource: vscode.Uri,
|
||||
line: number
|
||||
) {
|
||||
const key = resource.toString();
|
||||
if (!this.pendingUpdates.has(key)) {
|
||||
// schedule update
|
||||
setTimeout(() => {
|
||||
if (this.pendingUpdates.has(key)) {
|
||||
this._onChanged.fire({
|
||||
resource,
|
||||
line: this.pendingUpdates.get(key) as number
|
||||
});
|
||||
this.pendingUpdates.delete(key);
|
||||
}
|
||||
}, this.throttle);
|
||||
}
|
||||
|
||||
this.pendingUpdates.set(key, line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the top-most visible range of `editor`.
|
||||
*
|
||||
* Returns a fractional line number based the visible character within the line.
|
||||
* Floor to get real line number
|
||||
*/
|
||||
export function getVisibleLine(
|
||||
editor: vscode.TextEditor
|
||||
): number | undefined {
|
||||
if (!editor.visibleRanges.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstVisiblePosition = editor.visibleRanges[0].start;
|
||||
const lineNumber = firstVisiblePosition.line;
|
||||
const line = editor.document.lineAt(lineNumber);
|
||||
const progress = firstVisiblePosition.character / (line.text.length + 2);
|
||||
return lineNumber + progress;
|
||||
}
|
Reference in New Issue
Block a user