mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-21 09:08:53 +01:00
* Rework how markdown server works with documents This rewrites how the markdown server works with documents. The goal is to better handle switching between in-memory versions of a doc (from `TextDocument`) and versions of the same doc on disk. From the markdown service's POV, there is only one type of document As part of this, I've also adopted the newest markdown language service version * Bump package-lock versions
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { Connection, Emitter, FileChangeType, NotebookDocuments, Position, Range, TextDocuments } from 'vscode-languageserver';
|
|
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
import * as md from 'vscode-markdown-languageservice';
|
|
import { ContainingDocumentContext, FileWatcherOptions, IFileSystemWatcher } from 'vscode-markdown-languageservice/out/workspace';
|
|
import { URI } from 'vscode-uri';
|
|
import { LsConfiguration } from './config';
|
|
import * as protocol from './protocol';
|
|
import { isMarkdownFile, looksLikeMarkdownPath } from './util/file';
|
|
import { Limiter } from './util/limiter';
|
|
import { ResourceMap } from './util/resourceMap';
|
|
import { Schemes } from './util/schemes';
|
|
|
|
declare const TextDecoder: any;
|
|
|
|
class VsCodeDocument implements md.ITextDocument {
|
|
|
|
private inMemoryDoc?: TextDocument;
|
|
private onDiskDoc?: TextDocument;
|
|
|
|
readonly uri: string;
|
|
|
|
constructor(uri: string, init: { inMemoryDoc: TextDocument });
|
|
constructor(uri: string, init: { onDiskDoc: TextDocument });
|
|
constructor(uri: string, init: { inMemoryDoc?: TextDocument; onDiskDoc?: TextDocument }) {
|
|
this.uri = uri;
|
|
this.inMemoryDoc = init?.inMemoryDoc;
|
|
this.onDiskDoc = init?.onDiskDoc;
|
|
}
|
|
|
|
get version(): number {
|
|
return this.inMemoryDoc?.version ?? this.onDiskDoc?.version ?? 0;
|
|
}
|
|
|
|
get lineCount(): number {
|
|
return this.inMemoryDoc?.lineCount ?? this.onDiskDoc?.lineCount ?? 0;
|
|
}
|
|
|
|
getText(range?: Range): string {
|
|
if (this.inMemoryDoc) {
|
|
return this.inMemoryDoc.getText(range);
|
|
}
|
|
|
|
if (this.onDiskDoc) {
|
|
return this.onDiskDoc.getText(range);
|
|
}
|
|
|
|
throw new Error('Document has been closed');
|
|
}
|
|
|
|
positionAt(offset: number): Position {
|
|
if (this.inMemoryDoc) {
|
|
return this.inMemoryDoc.positionAt(offset);
|
|
}
|
|
|
|
if (this.onDiskDoc) {
|
|
return this.onDiskDoc.positionAt(offset);
|
|
}
|
|
|
|
throw new Error('Document has been closed');
|
|
}
|
|
|
|
isDetached(): boolean {
|
|
return !this.onDiskDoc && !this.inMemoryDoc;
|
|
}
|
|
|
|
setInMemoryDoc(doc: TextDocument | undefined) {
|
|
this.inMemoryDoc = doc;
|
|
}
|
|
|
|
setOnDiskDoc(doc: TextDocument | undefined) {
|
|
this.onDiskDoc = doc;
|
|
}
|
|
}
|
|
|
|
export class VsCodeClientWorkspace implements md.IWorkspaceWithWatching {
|
|
|
|
private readonly _onDidCreateMarkdownDocument = new Emitter<md.ITextDocument>();
|
|
public readonly onDidCreateMarkdownDocument = this._onDidCreateMarkdownDocument.event;
|
|
|
|
private readonly _onDidChangeMarkdownDocument = new Emitter<md.ITextDocument>();
|
|
public readonly onDidChangeMarkdownDocument = this._onDidChangeMarkdownDocument.event;
|
|
|
|
private readonly _onDidDeleteMarkdownDocument = new Emitter<URI>();
|
|
public readonly onDidDeleteMarkdownDocument = this._onDidDeleteMarkdownDocument.event;
|
|
|
|
private readonly _documentCache = new ResourceMap<VsCodeDocument>();
|
|
|
|
private readonly _utf8Decoder = new TextDecoder('utf-8');
|
|
|
|
private _watcherPool = 0;
|
|
private readonly _watchers = new Map<number, {
|
|
readonly resource: URI;
|
|
readonly options: FileWatcherOptions;
|
|
readonly onDidChange: Emitter<URI>;
|
|
readonly onDidCreate: Emitter<URI>;
|
|
readonly onDidDelete: Emitter<URI>;
|
|
}>();
|
|
|
|
constructor(
|
|
private readonly connection: Connection,
|
|
private readonly config: LsConfiguration,
|
|
private readonly documents: TextDocuments<TextDocument>,
|
|
private readonly notebooks: NotebookDocuments<TextDocument>,
|
|
private readonly logger: md.ILogger,
|
|
) {
|
|
documents.onDidOpen(e => {
|
|
if (!this.isRelevantMarkdownDocument(e.document)) {
|
|
return;
|
|
}
|
|
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: TextDocument.onDidOpen', `${e.document.uri}`);
|
|
|
|
const uri = URI.parse(e.document.uri);
|
|
const doc = this._documentCache.get(uri);
|
|
|
|
if (doc) {
|
|
// File already existed on disk
|
|
doc.setInMemoryDoc(e.document);
|
|
|
|
// The content visible to the language service may have changed since the in-memory doc
|
|
// may differ from the one on-disk. To be safe we always fire a change event.
|
|
this._onDidChangeMarkdownDocument.fire(doc);
|
|
} else {
|
|
// We're creating the file for the first time
|
|
const doc = new VsCodeDocument(e.document.uri, { inMemoryDoc: e.document });
|
|
this._documentCache.set(uri, doc);
|
|
this._onDidCreateMarkdownDocument.fire(doc);
|
|
}
|
|
});
|
|
|
|
documents.onDidChangeContent(e => {
|
|
if (!this.isRelevantMarkdownDocument(e.document)) {
|
|
return;
|
|
}
|
|
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: TextDocument.onDidChanceContent', `${e.document.uri}`);
|
|
|
|
const uri = URI.parse(e.document.uri);
|
|
const entry = this._documentCache.get(uri);
|
|
if (entry) {
|
|
entry.setInMemoryDoc(e.document);
|
|
this._onDidChangeMarkdownDocument.fire(entry);
|
|
}
|
|
});
|
|
|
|
documents.onDidClose(async e => {
|
|
if (!this.isRelevantMarkdownDocument(e.document)) {
|
|
return;
|
|
}
|
|
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: TextDocument.onDidClose', `${e.document.uri}`);
|
|
|
|
const uri = URI.parse(e.document.uri);
|
|
const doc = this._documentCache.get(uri);
|
|
if (!doc) {
|
|
// Document was never opened
|
|
return;
|
|
}
|
|
|
|
doc.setInMemoryDoc(undefined);
|
|
if (doc.isDetached()) {
|
|
// The document has been fully closed
|
|
this.doDeleteDocument(uri);
|
|
} else {
|
|
// The document still exists on disk
|
|
// To be safe, tell the service that the document has changed because the
|
|
// in-memory doc contents may be different than the disk doc contents.
|
|
this._onDidChangeMarkdownDocument.fire(doc);
|
|
}
|
|
});
|
|
|
|
connection.onDidChangeWatchedFiles(async ({ changes }) => {
|
|
for (const change of changes) {
|
|
const resource = URI.parse(change.uri);
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: onDidChangeWatchedFiles', `${change.type}: ${resource}`);
|
|
switch (change.type) {
|
|
case FileChangeType.Changed: {
|
|
const entry = this._documentCache.get(resource);
|
|
if (entry) {
|
|
// Refresh the on-disk state
|
|
const document = await this.openMarkdownDocumentFromFs(resource);
|
|
if (document) {
|
|
this._onDidChangeMarkdownDocument.fire(document);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case FileChangeType.Created: {
|
|
const entry = this._documentCache.get(resource);
|
|
if (entry) {
|
|
// Create or update the on-disk state
|
|
const document = await this.openMarkdownDocumentFromFs(resource);
|
|
if (document) {
|
|
this._onDidCreateMarkdownDocument.fire(document);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case FileChangeType.Deleted: {
|
|
const entry = this._documentCache.get(resource);
|
|
if (entry) {
|
|
entry.setOnDiskDoc(undefined);
|
|
if (entry.isDetached()) {
|
|
this.doDeleteDocument(resource);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
connection.onRequest(protocol.fs_watcher_onChange, params => {
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: fs_watcher_onChange', `${params.kind}: ${params.uri}`);
|
|
|
|
const watcher = this._watchers.get(params.id);
|
|
if (!watcher) {
|
|
return;
|
|
}
|
|
|
|
switch (params.kind) {
|
|
case 'create': watcher.onDidCreate.fire(URI.parse(params.uri)); return;
|
|
case 'change': watcher.onDidChange.fire(URI.parse(params.uri)); return;
|
|
case 'delete': watcher.onDidDelete.fire(URI.parse(params.uri)); return;
|
|
}
|
|
});
|
|
}
|
|
|
|
public listen() {
|
|
this.connection.workspace.onDidChangeWorkspaceFolders(async () => {
|
|
this.workspaceFolders = (await this.connection.workspace.getWorkspaceFolders() ?? []).map(x => URI.parse(x.uri));
|
|
});
|
|
}
|
|
|
|
private _workspaceFolders: readonly URI[] = [];
|
|
|
|
get workspaceFolders(): readonly URI[] {
|
|
return this._workspaceFolders;
|
|
}
|
|
|
|
set workspaceFolders(value: readonly URI[]) {
|
|
this._workspaceFolders = value;
|
|
}
|
|
|
|
async getAllMarkdownDocuments(): Promise<Iterable<md.ITextDocument>> {
|
|
// Add opened files (such as untitled files)
|
|
const openTextDocumentResults = this.documents.all()
|
|
.filter(doc => this.isRelevantMarkdownDocument(doc));
|
|
|
|
const allDocs = new ResourceMap<md.ITextDocument>();
|
|
for (const doc of openTextDocumentResults) {
|
|
allDocs.set(URI.parse(doc.uri), doc);
|
|
}
|
|
|
|
// And then add files on disk
|
|
const maxConcurrent = 20;
|
|
const limiter = new Limiter<md.ITextDocument | undefined>(maxConcurrent);
|
|
const resources = await this.connection.sendRequest(protocol.findMarkdownFilesInWorkspace, {});
|
|
await Promise.all(resources.map(strResource => {
|
|
return limiter.queue(async () => {
|
|
const resource = URI.parse(strResource);
|
|
if (allDocs.has(resource)) {
|
|
return;
|
|
}
|
|
|
|
const doc = await this.openMarkdownDocument(resource);
|
|
if (doc) {
|
|
allDocs.set(resource, doc);
|
|
}
|
|
return doc;
|
|
});
|
|
}));
|
|
|
|
return allDocs.values();
|
|
}
|
|
|
|
hasMarkdownDocument(resource: URI): boolean {
|
|
return !!this.documents.get(resource.toString());
|
|
}
|
|
|
|
async openMarkdownDocument(resource: URI): Promise<md.ITextDocument | undefined> {
|
|
const existing = this._documentCache.get(resource);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const matchingDocument = this.documents.get(resource.toString());
|
|
if (matchingDocument) {
|
|
let entry = this._documentCache.get(resource);
|
|
if (entry) {
|
|
entry.setInMemoryDoc(matchingDocument);
|
|
} else {
|
|
entry = new VsCodeDocument(resource.toString(), { inMemoryDoc: matchingDocument });
|
|
this._documentCache.set(resource, entry);
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
return this.openMarkdownDocumentFromFs(resource);
|
|
}
|
|
|
|
private async openMarkdownDocumentFromFs(resource: URI): Promise<md.ITextDocument | undefined> {
|
|
if (!looksLikeMarkdownPath(this.config, resource)) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const response = await this.connection.sendRequest(protocol.fs_readFile, { uri: resource.toString() });
|
|
// TODO: LSP doesn't seem to handle Array buffers well
|
|
const bytes = new Uint8Array(response);
|
|
|
|
// We assume that markdown is in UTF-8
|
|
const text = this._utf8Decoder.decode(bytes);
|
|
const doc = new VsCodeDocument(resource.toString(), {
|
|
onDiskDoc: TextDocument.create(resource.toString(), 'markdown', 0, text)
|
|
});
|
|
this._documentCache.set(resource, doc);
|
|
return doc;
|
|
} catch (e) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async stat(resource: URI): Promise<md.FileStat | undefined> {
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: stat', `${resource}`);
|
|
if (this._documentCache.has(resource) || this.documents.get(resource.toString())) {
|
|
return { isDirectory: false };
|
|
}
|
|
return this.connection.sendRequest(protocol.fs_stat, { uri: resource.toString() });
|
|
}
|
|
|
|
async readDirectory(resource: URI): Promise<[string, md.FileStat][]> {
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: readDir', `${resource}`);
|
|
return this.connection.sendRequest(protocol.fs_readDirectory, { uri: resource.toString() });
|
|
}
|
|
|
|
getContainingDocument(resource: URI): ContainingDocumentContext | undefined {
|
|
if (resource.scheme === Schemes.notebookCell) {
|
|
const nb = this.notebooks.findNotebookDocumentForCell(resource.toString());
|
|
if (nb) {
|
|
return {
|
|
uri: URI.parse(nb.uri),
|
|
children: nb.cells.map(cell => ({ uri: URI.parse(cell.document) })),
|
|
};
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
watchFile(resource: URI, options: FileWatcherOptions): IFileSystemWatcher {
|
|
const id = this._watcherPool++;
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: watchFile', `(${id}) ${resource}`);
|
|
|
|
const entry = {
|
|
resource,
|
|
options,
|
|
onDidCreate: new Emitter<URI>(),
|
|
onDidChange: new Emitter<URI>(),
|
|
onDidDelete: new Emitter<URI>(),
|
|
};
|
|
this._watchers.set(id, entry);
|
|
|
|
this.connection.sendRequest(protocol.fs_watcher_create, {
|
|
id,
|
|
uri: resource.toString(),
|
|
options,
|
|
watchParentDirs: true,
|
|
});
|
|
|
|
return {
|
|
onDidCreate: entry.onDidCreate.event,
|
|
onDidChange: entry.onDidChange.event,
|
|
onDidDelete: entry.onDidDelete.event,
|
|
dispose: () => {
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: disposeWatcher', `(${id}) ${resource}`);
|
|
this.connection.sendRequest(protocol.fs_watcher_delete, { id });
|
|
this._watchers.delete(id);
|
|
}
|
|
};
|
|
}
|
|
|
|
private isRelevantMarkdownDocument(doc: TextDocument) {
|
|
return isMarkdownFile(doc) && URI.parse(doc.uri).scheme !== 'vscode-bulkeditpreview';
|
|
}
|
|
|
|
private doDeleteDocument(uri: URI) {
|
|
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: deleteDocument', `${uri}`);
|
|
|
|
this._documentCache.delete(uri);
|
|
this._onDidDeleteMarkdownDocument.fire(uri);
|
|
}
|
|
}
|