From d883351846651568394cefdae101ad04696bec13 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 4 Sep 2026 13:22:36 -0700 Subject: [PATCH 01/11] Port content mapper inspector extension into bundled extension --- .../vscode-typescript/l10n/bundle.l10n.json | 19 + packages/vscode-typescript/package.json | 27 + packages/vscode-typescript/package.nls.json | 2 + packages/vscode-typescript/src/client.ts | 27 + .../src/contentMapperVirtualDocuments.ts | 701 ++++++++++++++++++ .../src/contentMapperVirtualFiles.ts | 44 ++ .../src/diagnosticDirectivesView.ts | 170 +++++ packages/vscode-typescript/src/extension.ts | 6 +- packages/vscode-typescript/src/session.ts | 15 + .../test/contentMapperVirtualFiles.test.ts | 21 + packages/vscode-typescript/test/index.test.ts | 1 + .../lsp/lsproto/_generate/generate.mts | 86 +++ tsc/internal/lsp/lsproto/lsp_generated.go | 142 ++++ tsc/internal/lsp/server.go | 86 +++ tsc/internal/lsp/server_contentmapper_test.go | 28 + 15 files changed, 1374 insertions(+), 1 deletion(-) create mode 100644 packages/vscode-typescript/src/contentMapperVirtualDocuments.ts create mode 100644 packages/vscode-typescript/src/contentMapperVirtualFiles.ts create mode 100644 packages/vscode-typescript/src/diagnosticDirectivesView.ts create mode 100644 packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts diff --git a/packages/vscode-typescript/l10n/bundle.l10n.json b/packages/vscode-typescript/l10n/bundle.l10n.json index d33a2666e490f..cd1821375b8ed 100644 --- a/packages/vscode-typescript/l10n/bundle.l10n.json +++ b/packages/vscode-typescript/l10n/bundle.l10n.json @@ -65,6 +65,25 @@ "{0} extensions contribute TypeScript server plugins that will not be loaded because TypeScript 7 is enabled globally: {1}": "{0} extensions contribute TypeScript server plugins that will not be loaded because TypeScript 7 is enabled globally: {1}", "Disable Native Preview in Workspace": "Disable Native Preview in Workspace", "Don't Show Again": "Don't Show Again", + "Open virtual documents to inspect diagnostic directives.": "Open virtual documents to inspect diagnostic directives.", + "{0} {1}": "{0} {1}", + "Reveal Diagnostic Directive": "Reveal Diagnostic Directive", + "The current content-mapped file has no diagnostic directives.": "The current content-mapped file has no diagnostic directives.", + "Original range: {0}": "Original range: {0}", + "Virtual range: {0}": "Virtual range: {0}", + "Unused diagnostic code: {0}": "Unused diagnostic code: {0}", + "Expect": "Expect", + "Unknown ({0})": "Unknown ({0})", + "Could not reveal diagnostic directive: {0}": "Could not reveal diagnostic directive: {0}", + "Open a content-mapped source file to show its virtual TypeScript documents.": "Open a content-mapped source file to show its virtual TypeScript documents.", + "The active file is not transformed by a TypeScript content mapper.": "The active file is not transformed by a TypeScript content mapper.", + "Content mapper inspector is unavailable: {0}": "Content mapper inspector is unavailable: {0}", + "Span kind: {0}": "Span kind: {0}", + "Features: {0}": "Features: {0}", + "None": "None", + "Verbatim": "Verbatim", + "Atom": "Atom", + "Alias": "Alias", "Unexpected number of arguments.": "Unexpected number of arguments.", "Starting language server...": "Starting language server...", "Language client is not initialized": "Language client is not initialized", diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 3be3c59bd7b72..a5ab6e53276b2 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -224,6 +224,16 @@ } } ], + "views": { + "explorer": [ + { + "id": "typescript.native-preview.contentMapperDiagnosticDirectives", + "name": "%native-preview.contentMapperDiagnosticDirectives.name%", + "icon": "$(list-tree)", + "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo" + } + ] + }, "commands": [ { "command": "typescript.native-preview.enable", @@ -267,6 +277,12 @@ "enablement": "typescript.native-preview.serverRunning", "category": "TypeScript" }, + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "title": "%native-preview.showContentMapperVirtualDocuments.title%", + "enablement": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && typescript.native-preview.activeEditorIsContentMapped", + "category": "TypeScript" + }, { "command": "typescript.native-preview.sortImports", "title": "%native-preview.sortImports.title%", @@ -339,6 +355,10 @@ "command": "typescript.native-preview.goToSourceDefinition", "when": "typescript.native-preview.serverRunning && tsSupportsSourceDefinition" }, + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && typescript.native-preview.activeEditorIsContentMapped" + }, { "command": "typescript.native-preview.sortImports", "when": "typescript.native-preview.serverRunning && supportedCodeAction =~ /(\\s|^)source\\.sortImports\\b/ && editorLangId =~ /^(typescript|javascript)(react)?$/" @@ -354,6 +374,13 @@ "when": "typescript.native-preview.serverRunning && tsSupportsSourceDefinition && (resourceLangId == typescript || resourceLangId == typescriptreact || resourceLangId == javascript || resourceLangId == javascriptreact)", "group": "navigation@1.41" } + ], + "editor/title/context": [ + { + "command": "typescript.native-preview.showContentMapperVirtualDocuments", + "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && typescript.native-preview.activeEditorIsContentMapped", + "group": "navigation@1.5" + } ] } }, diff --git a/packages/vscode-typescript/package.nls.json b/packages/vscode-typescript/package.nls.json index 437a36410004f..b777e403a6261 100644 --- a/packages/vscode-typescript/package.nls.json +++ b/packages/vscode-typescript/package.nls.json @@ -23,6 +23,8 @@ "native-preview.reportIssue.title": "Report Issue", "native-preview.selectVersion.title": "Select TypeScript Version...", "native-preview.goToSourceDefinition.title": "Go to Source Definition", + "native-preview.showContentMapperVirtualDocuments.title": "Show Content Mapper Virtual Documents", + "native-preview.contentMapperDiagnosticDirectives.name": "Content Mapper Diagnostic Directives", "native-preview.sortImports.title": "Sort Imports", "native-preview.removeUnusedImports.title": "Remove Unused Imports", "native-preview.codeLens.showLocations.title": "Show References of CodeLens", diff --git a/packages/vscode-typescript/src/client.ts b/packages/vscode-typescript/src/client.ts index b8bd17df8540a..447c5082b9dd5 100644 --- a/packages/vscode-typescript/src/client.ts +++ b/packages/vscode-typescript/src/client.ts @@ -25,6 +25,11 @@ import { sendNotificationMiddleware, } from "./configurationMiddleware"; import type { SerializedContentMapperContribution } from "./contentMapperContributions"; +import { + type ContentMapperVirtualFile, + type MappedOutput, + toMappedOutputs, +} from "./contentMapperVirtualFiles"; import { registerMultiDocumentHighlightFeature } from "./languageFeatures/documentHighlight"; import { registerHoverFeature } from "./languageFeatures/hover"; import { registerOnAutoInsertFeature } from "./languageFeatures/onAutoInsert"; @@ -436,6 +441,28 @@ export class Client implements vscode.Disposable { return this.client.sendRequest<{ sessionId: string; pipe: string; }>("custom/initializeAPISession", { pipe }); } + async getContentMapperVirtualFiles(uri: vscode.Uri): Promise { + if (!this.client) { + throw new Error(vscode.l10n.t("Language client is not initialized")); + } + const result = await this.client.sendRequest<{ files: ContentMapperVirtualFile[]; }>( + "custom/contentMapperVirtualFiles", + { textDocument: { uri: uri.toString() } }, + ); + return toMappedOutputs(result.files); + } + + async isContentMapped(uri: vscode.Uri): Promise { + if (!this.client) { + throw new Error(vscode.l10n.t("Language client is not initialized")); + } + const result = await this.client.sendRequest<{ isContentMapped: boolean; }>( + "custom/isContentMapped", + { textDocument: { uri: uri.toString() } }, + ); + return result.isContentMapped; + } + /** * Restart the language server if the executable path has not changed. * Returns true if a restart was performed. diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts new file mode 100644 index 0000000000000..52ad7dde746e9 --- /dev/null +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -0,0 +1,701 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import type { + ContentMapperTextRange, + ContentMapperVirtualSpan, + MappedOutput, +} from "./contentMapperVirtualFiles"; +import { + type DiagnosticDirectiveNode, + DiagnosticDirectivesView, +} from "./diagnosticDirectivesView"; + +const virtualDocumentScheme = "typescript-content-mapper"; +const activeEditorIsContentMappedContext = "typescript.native-preview.activeEditorIsContentMapped"; +export const showVirtualDocumentsCommand = "typescript.native-preview.showContentMapperVirtualDocuments"; + +export interface ContentMapperVirtualFilesProvider { + readonly onDidInitializeLanguageServer: vscode.Event; + getContentMapperVirtualFiles(uri: vscode.Uri): Promise; + isContentMapped(uri: vscode.Uri): Promise; +} + +interface VirtualDocumentEntry { + readonly sourceUri: vscode.Uri; + output: MappedOutput; + mtime: number; +} + +export function registerContentMapperVirtualDocumentProvider( + provider: ContentMapperVirtualFilesProvider, + output: vscode.LogOutputChannel, +): vscode.Disposable { + return new ContentMapperVirtualDocumentProvider(provider, output); +} + +class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly entries = new Map(); + private readonly sourceToVirtualUris = new Map(); + private readonly refreshTimers = new Map(); + private inspectionTimer: NodeJS.Timeout | undefined; + private activeEditorContextVersion = 0; + private readonly mappingDecorations = [ + vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(70, 180, 90, 0.18)", + border: "1px solid rgba(70, 180, 90, 0.8)", + }), + vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(230, 165, 35, 0.18)", + border: "1px solid rgba(230, 165, 35, 0.85)", + }), + vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(65, 145, 235, 0.18)", + border: "1px solid rgba(65, 145, 235, 0.85)", + }), + ]; + private readonly decoratedEditors = new Set(); + private readonly highlightedMappings = new Map(); + private readonly diagnosticDirectivesView: DiagnosticDirectivesView; + private readonly disposables: vscode.Disposable[]; + + readonly onDidChangeFile = this.changeEmitter.event; + + constructor( + private readonly provider: ContentMapperVirtualFilesProvider, + private readonly output: vscode.LogOutputChannel, + ) { + this.diagnosticDirectivesView = new DiagnosticDirectivesView(node => { + void this.revealDiagnosticDirective(node).catch(error => { + this.output.error(`Could not reveal diagnostic directive: ${String(error)}`); + void vscode.window.showErrorMessage(vscode.l10n.t("Could not reveal diagnostic directive: {0}", errorMessage(error))); + }); + }); + this.disposables = [ + this.changeEmitter, + this.diagnosticDirectivesView, + ...this.mappingDecorations, + vscode.workspace.registerFileSystemProvider(virtualDocumentScheme, this, { + isCaseSensitive: true, + isReadonly: true, + }), + vscode.languages.registerHoverProvider( + { scheme: virtualDocumentScheme }, + { provideHover: (document, position) => this.provideMappingHover(document, position) }, + ), + vscode.commands.registerCommand(showVirtualDocumentsCommand, () => this.showActiveDocument()), + vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.scheme === virtualDocumentScheme) { + this.scheduleInspection(); + } + else { + this.scheduleRefresh(event.document.uri); + } + }), + vscode.workspace.onDidSaveTextDocument(document => this.scheduleRefresh(document.uri)), + vscode.languages.onDidChangeDiagnostics(event => { + for (const uri of event.uris) { + this.scheduleRefresh(uri); + } + }), + vscode.window.onDidChangeActiveTextEditor(editor => { + this.updateActiveEditorContext(editor); + if (editor?.document.uri.scheme === virtualDocumentScheme) { + const entry = this.entries.get(editor.document.uri.toString()); + if (entry) { + this.refreshSource(entry.sourceUri); + } + } + this.scheduleInspection(); + }), + vscode.window.onDidChangeTextEditorSelection(event => { + if (event.textEditor === vscode.window.activeTextEditor) { + this.scheduleInspection(); + } + }), + vscode.window.onDidChangeVisibleTextEditors(() => this.scheduleInspection()), + provider.onDidInitializeLanguageServer(() => { + this.updateActiveEditorContext(vscode.window.activeTextEditor); + for (const source of this.sourceToVirtualUris.keys()) { + this.refreshSource(vscode.Uri.parse(source)); + } + }), + ]; + this.updateActiveEditorContext(vscode.window.activeTextEditor); + } + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + async stat(uri: vscode.Uri): Promise { + const entry = await this.getOrCreateEntry(uri); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + return { + type: vscode.FileType.File, + ctime: 0, + mtime: entry.mtime, + size: Buffer.byteLength(entry.output.text), + permissions: vscode.FilePermission.Readonly, + }; + } + + readDirectory(): [string, vscode.FileType][] { + return []; + } + + createDirectory(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + async readFile(uri: vscode.Uri): Promise { + const entry = await this.getOrCreateEntry(uri); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + return Buffer.from(entry.output.text); + } + + writeFile(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + delete(uri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(uri); + } + + rename(oldUri: vscode.Uri): void { + throw vscode.FileSystemError.NoPermissions(oldUri); + } + + dispose(): void { + this.activeEditorContextVersion++; + void vscode.commands.executeCommand("setContext", activeEditorIsContentMappedContext, false); + for (const timer of this.refreshTimers.values()) { + clearTimeout(timer); + } + this.refreshTimers.clear(); + if (this.inspectionTimer) { + clearTimeout(this.inspectionTimer); + this.inspectionTimer = undefined; + } + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + } + + private async getOrCreateEntry(uri: vscode.Uri): Promise { + const existing = this.entries.get(uri.toString()); + if (existing) { + return existing; + } + const parsed = parseVirtualUri(uri); + if (!parsed) { + return undefined; + } + const outputs = await this.loadOutputs(parsed.sourceUri); + this.remember(parsed.sourceUri, outputs); + return this.entries.get(uri.toString()); + } + + private async showActiveDocument(): Promise { + const sourceEditor = vscode.window.activeTextEditor; + const sourceUri = sourceEditor?.document.uri; + if (!sourceEditor || !sourceUri || sourceUri.scheme === virtualDocumentScheme) { + void vscode.window.showInformationMessage(vscode.l10n.t("Open a content-mapped source file to show its virtual TypeScript documents.")); + return; + } + + try { + const outputs = await this.loadOutputs(sourceUri); + if (outputs.length === 0) { + this.diagnosticDirectivesView.show(sourceUri, []); + void vscode.window.showInformationMessage(vscode.l10n.t("The active file is not transformed by a TypeScript content mapper.")); + return; + } + const previousUris = this.sourceToVirtualUris.get(sourceUri.toString()) ?? []; + const previousEntries = new Map(previousUris.map( + uri => [uri.toString(), this.entries.get(uri.toString())] as const, + )); + const virtualUris = this.remember(sourceUri, outputs); + this.diagnosticDirectivesView.show(sourceUri, outputs); + const nextKeys = new Set(virtualUris.map(uri => uri.toString())); + const changes: vscode.FileChangeEvent[] = []; + for (const uri of previousUris) { + if (!nextKeys.has(uri.toString())) { + changes.push({ type: vscode.FileChangeType.Deleted, uri }); + } + } + for (const uri of virtualUris) { + const previous = previousEntries.get(uri.toString()); + const entry = this.entries.get(uri.toString())!; + if (!previous) { + changes.push({ type: vscode.FileChangeType.Created, uri }); + } + else if (previous.output.identity !== entry.output.identity) { + entry.mtime = Math.max(Date.now(), previous.mtime + 1); + changes.push({ type: vscode.FileChangeType.Changed, uri }); + } + } + if (changes.length !== 0) { + this.changeEmitter.fire(changes); + } + const targetColumn = sourceEditor.viewColumn === undefined + ? vscode.ViewColumn.Beside + : sourceEditor.viewColumn + 1; + + for (let index = virtualUris.length - 1; index >= 0; index--) { + const uri = virtualUris[index]!; + const entry = this.entries.get(uri.toString())!; + let document = await vscode.workspace.openTextDocument(uri); + document = await vscode.languages.setTextDocumentLanguage(document, languageIdForScriptKind(entry.output.scriptKind)); + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: index !== 0, + viewColumn: targetColumn, + }); + } + this.scheduleInspection(); + } + catch (error) { + this.output.error(`Could not show content mapper virtual documents: ${String(error)}`); + void vscode.window.showInformationMessage(vscode.l10n.t("Content mapper inspector is unavailable: {0}", errorMessage(error))); + } + } + + private loadOutputs(sourceUri: vscode.Uri): Promise { + return this.provider.getContentMapperVirtualFiles(sourceUri); + } + + private updateActiveEditorContext(editor: vscode.TextEditor | undefined): void { + const version = ++this.activeEditorContextVersion; + void this.updateActiveEditorContextNow(editor, version).catch(error => { + this.output.error(`Could not update the active content mapper context: ${String(error)}`); + }); + } + + private async updateActiveEditorContextNow(editor: vscode.TextEditor | undefined, version: number): Promise { + let isContentMapped = false; + if (editor?.document.uri.scheme === "file") { + try { + isContentMapped = await this.provider.isContentMapped(editor.document.uri); + } + catch (error) { + this.output.debug(`Could not determine whether ${editor.document.uri.toString()} is content-mapped: ${String(error)}`); + } + } + if (version === this.activeEditorContextVersion) { + await vscode.commands.executeCommand("setContext", activeEditorIsContentMappedContext, isContentMapped); + } + } + + private remember(sourceUri: vscode.Uri, outputs: readonly MappedOutput[]): readonly vscode.Uri[] { + const sourceKey = sourceUri.toString(); + const previousUris = this.sourceToVirtualUris.get(sourceKey) ?? []; + const nextUris = outputs.map(output => virtualUriForOutput(sourceUri, output)); + const nextKeys = new Set(nextUris.map(uri => uri.toString())); + + for (const previousUri of previousUris) { + if (!nextKeys.has(previousUri.toString())) { + this.entries.delete(previousUri.toString()); + } + } + + outputs.forEach((mappedOutput, index) => { + const uri = nextUris[index]!; + const existing = this.entries.get(uri.toString()); + this.entries.set(uri.toString(), { + sourceUri, + output: mappedOutput, + mtime: existing?.mtime ?? Date.now(), + }); + }); + this.sourceToVirtualUris.set(sourceKey, nextUris); + return nextUris; + } + + private scheduleRefresh(sourceUri: vscode.Uri): void { + const sourceKey = sourceUri.toString(); + if (!this.sourceToVirtualUris.has(sourceKey)) { + return; + } + const existing = this.refreshTimers.get(sourceKey); + if (existing) { + clearTimeout(existing); + } + this.refreshTimers.set( + sourceKey, + setTimeout(() => { + this.refreshTimers.delete(sourceKey); + this.refreshSource(sourceUri); + }, 100), + ); + } + + private refreshSource(sourceUri: vscode.Uri): void { + void this.refresh(sourceUri).catch(error => { + this.output.warn(`Could not refresh ${sourceUri.toString()}: ${String(error)}`); + }); + } + + private async refresh(sourceUri: vscode.Uri): Promise { + const sourceKey = sourceUri.toString(); + const previousUris = this.sourceToVirtualUris.get(sourceKey); + if (!previousUris) { + return; + } + + const outputs = await this.loadOutputs(sourceUri); + if (outputs.length === 0) { + this.clearInspection(); + this.diagnosticDirectivesView.refresh(sourceUri, undefined); + this.sourceToVirtualUris.delete(sourceKey); + const changes = previousUris.map(uri => ({ type: vscode.FileChangeType.Deleted, uri })); + for (const uri of previousUris) { + this.entries.delete(uri.toString()); + } + this.changeEmitter.fire(changes); + return; + } + + const previousEntries = new Map(previousUris.map(uri => [uri.toString(), this.entries.get(uri.toString())])); + const nextUris = this.remember(sourceUri, outputs); + this.diagnosticDirectivesView.refresh(sourceUri, outputs); + const nextKeys = new Set(nextUris.map(uri => uri.toString())); + const changes: vscode.FileChangeEvent[] = []; + + for (const uri of previousUris) { + if (!nextKeys.has(uri.toString())) { + changes.push({ type: vscode.FileChangeType.Deleted, uri }); + } + } + for (const uri of nextUris) { + const entry = this.entries.get(uri.toString())!; + const previous = previousEntries.get(uri.toString()); + if (!previous) { + changes.push({ type: vscode.FileChangeType.Created, uri }); + } + else if (previous.output.identity !== entry.output.identity) { + entry.mtime = Math.max(Date.now(), previous.mtime + 1); + changes.push({ type: vscode.FileChangeType.Changed, uri }); + } + } + if (changes.length !== 0) { + this.changeEmitter.fire(changes); + } + this.scheduleInspection(); + } + + private async revealDiagnosticDirective(node: DiagnosticDirectiveNode): Promise { + const sourceUri = this.sourceUriForOutput(node.output); + if (!sourceUri) { + return; + } + const virtualUri = virtualUriForOutput(sourceUri, node.output); + const entry = await this.getOrCreateEntry(virtualUri); + if (!entry) { + throw new Error(`Could not load virtual document "${node.output.fileName}".`); + } + + const sourceDocument = await vscode.workspace.openTextDocument(sourceUri); + const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); + await vscode.window.showTextDocument(sourceDocument, { + preserveFocus: true, + preview: false, + selection: sourceRange, + }); + + let virtualDocument = await vscode.workspace.openTextDocument(virtualUri); + virtualDocument = await vscode.languages.setTextDocumentLanguage( + virtualDocument, + languageIdForScriptKind(entry.output.scriptKind), + ); + const virtualRange = rangeFromTextRange(virtualDocument, node.directive.virtualRange); + const virtualEditor = await vscode.window.showTextDocument(virtualDocument, { + preserveFocus: false, + preview: false, + selection: virtualRange, + viewColumn: vscode.ViewColumn.Beside, + }); + virtualEditor.revealRange(virtualRange, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + this.scheduleInspection(); + } + + private sourceUriForOutput(output: MappedOutput): vscode.Uri | undefined { + for (const entry of this.entries.values()) { + if (entry.output === output) { + return entry.sourceUri; + } + } + return undefined; + } + + private scheduleInspection(): void { + if (this.inspectionTimer) { + clearTimeout(this.inspectionTimer); + } + this.inspectionTimer = setTimeout(() => { + this.inspectionTimer = undefined; + this.inspectActiveSelection(); + }, 0); + } + + private inspectActiveSelection(): void { + const editor = vscode.window.activeTextEditor; + if (editor) { + this.inspectSelection(editor); + } + else { + this.clearInspection(); + } + } + + private inspectSelection(editor: vscode.TextEditor): void { + if (editor.document.uri.scheme === virtualDocumentScheme) { + this.inspectVirtualSelection(editor); + } + else { + this.inspectSourceSelection(editor); + } + } + + private inspectVirtualSelection(virtualEditor: vscode.TextEditor): void { + const entry = this.entries.get(virtualEditor.document.uri.toString()); + if (!entry) { + this.clearInspection(); + return; + } + const sourceEditor = vscode.window.visibleTextEditors.find( + candidate => candidate.document.uri.toString() === entry.sourceUri.toString(), + ); + if (!sourceEditor) { + this.clearInspection(); + return; + } + const offset = virtualEditor.document.offsetAt(virtualEditor.selection.active); + const mappings = entry.output.mappings.filter( + mapping => containsOffset(mapping.generatedStart, mapping.generatedLength, offset), + ); + this.clearInspection(); + this.decorateMappingPair(sourceEditor, virtualEditor, mappings); + } + + private inspectSourceSelection(sourceEditor: vscode.TextEditor): void { + const virtualUris = this.sourceToVirtualUris.get(sourceEditor.document.uri.toString()); + if (!virtualUris) { + this.clearInspection(); + return; + } + const offset = sourceEditor.document.offsetAt(sourceEditor.selection.active); + this.clearInspection(); + const sourceRanges: vscode.Range[][] = [[], [], []]; + for (const virtualUri of virtualUris) { + const entry = this.entries.get(virtualUri.toString()); + const virtualEditor = vscode.window.visibleTextEditors.find( + candidate => candidate.document.uri.toString() === virtualUri.toString(), + ); + if (!entry || !virtualEditor) { + continue; + } + const mappings = entry.output.mappings.filter( + mapping => containsOffset(mapping.originalStart, mapping.originalLength, offset), + ); + if (mappings.length === 0) { + continue; + } + const virtualRanges: vscode.Range[][] = [[], [], []]; + for (const mapping of mappings) { + const kind = normalizedMappingKind(mapping.kind); + sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); + virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + } + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + virtualEditor.setDecorations(this.mappingDecorations[kind]!, virtualRanges[kind]!); + } + this.decoratedEditors.add(virtualEditor); + this.highlightedMappings.set(virtualEditor.document.uri.toString(), mappings); + } + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + sourceEditor.setDecorations(this.mappingDecorations[kind]!, sourceRanges[kind]!); + } + if (sourceRanges.some(ranges => ranges.length !== 0)) { + this.decoratedEditors.add(sourceEditor); + } + } + + private decorateMappingPair( + sourceEditor: vscode.TextEditor, + virtualEditor: vscode.TextEditor, + mappings: readonly ContentMapperVirtualSpan[], + ): void { + if (mappings.length === 0) { + return; + } + + const sourceRanges: vscode.Range[][] = [[], [], []]; + const virtualRanges: vscode.Range[][] = [[], [], []]; + for (const mapping of mappings) { + const kind = normalizedMappingKind(mapping.kind); + sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); + virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + } + for (let kind = 0; kind < this.mappingDecorations.length; kind++) { + sourceEditor.setDecorations(this.mappingDecorations[kind]!, sourceRanges[kind]!); + virtualEditor.setDecorations(this.mappingDecorations[kind]!, virtualRanges[kind]!); + } + this.decoratedEditors.add(sourceEditor); + this.decoratedEditors.add(virtualEditor); + this.highlightedMappings.set(virtualEditor.document.uri.toString(), mappings); + } + + private clearInspection(): void { + for (const editor of this.decoratedEditors) { + for (const decoration of this.mappingDecorations) { + editor.setDecorations(decoration, []); + } + } + this.decoratedEditors.clear(); + this.highlightedMappings.clear(); + } + + private provideMappingHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | undefined { + const highlighted = this.highlightedMappings.get(document.uri.toString()); + if (!highlighted) { + return undefined; + } + const offset = document.offsetAt(position); + const mappings = highlighted.filter(mapping => containsOffset(mapping.generatedStart, mapping.generatedLength, offset)); + if (mappings.length === 0) { + return undefined; + } + + const contents = new vscode.MarkdownString(); + mappings.forEach((mapping, index) => { + if (index !== 0) { + contents.appendMarkdown("\n\n---\n\n"); + } + contents.appendMarkdown(`**${vscode.l10n.t("Span kind: {0}", mappingKindName(mapping.kind))}**`); + const features = featureNames(mapping.features); + contents.appendMarkdown(`\n\n${vscode.l10n.t("Features: {0}", features.join(", ") || vscode.l10n.t("None"))}`); + }); + const first = mappings[0]!; + return new vscode.Hover(contents, rangeFromOffsets(document, first.generatedStart, first.generatedLength)); + } +} + +function containsOffset(start: number, length: number, offset: number): boolean { + return length === 0 ? offset === start : start <= offset && offset < start + length; +} + +function rangeFromOffsets(document: vscode.TextDocument, start: number, length: number): vscode.Range { + return new vscode.Range(document.positionAt(start), document.positionAt(start + length)); +} + +function rangeFromTextRange(document: vscode.TextDocument, range: ContentMapperTextRange): vscode.Range { + return new vscode.Range(document.positionAt(range.pos), document.positionAt(range.end)); +} + +function normalizedMappingKind(kind: number): number { + return kind >= 0 && kind <= 2 ? kind : 1; +} + +function mappingKindName(kind: number): string { + switch (kind) { + case 0: + return vscode.l10n.t("Verbatim"); + case 1: + return vscode.l10n.t("Atom"); + case 2: + return vscode.l10n.t("Alias"); + default: + return vscode.l10n.t("Unknown ({0})", kind); + } +} + +const featureLabels = [ + "Hover", + "Signature Help", + "Completion", + "Definition", + "Type Definition", + "Implementation", + "References", + "Document Highlights", + "Rename", + "Call Hierarchy", + "Code Actions", + "Formatting", + "Inlay Hints", + "Semantic Tokens", + "Folding Ranges", + "Selection Ranges", + "Linked Editing", + "Auto Insert", + "Document Symbols", + "CodeLens", +] as const; + +function featureNames(features: number): string[] { + return featureLabels.filter((_, index) => (features & (1 << index)) !== 0); +} + +function virtualUriForOutput(sourceUri: vscode.Uri, output: MappedOutput): vscode.Uri { + return vscode.Uri.from({ + scheme: virtualDocumentScheme, + path: `/${virtualFileName(output)}`, + query: new URLSearchParams({ + source: sourceUri.toString(), + output: output.key, + }).toString(), + }); +} + +function virtualFileName(output: MappedOutput): string { + const fileName = path.basename(output.fileName); + const extension = extensionForScriptKind(output.scriptKind); + return fileName.toLowerCase().endsWith(extension) ? fileName : fileName + extension; +} + +function parseVirtualUri(uri: vscode.Uri): { readonly sourceUri: vscode.Uri; readonly outputKey: string; } | undefined { + const params = new URLSearchParams(uri.query); + const source = params.get("source"); + const outputKey = params.get("output"); + return source && outputKey ? { sourceUri: vscode.Uri.parse(source), outputKey } : undefined; +} + +function extensionForScriptKind(scriptKind: number): string { + switch (scriptKind) { + case 1: + return ".js"; + case 2: + return ".jsx"; + case 4: + return ".tsx"; + case 6: + return ".json"; + default: + return ".ts"; + } +} + +function languageIdForScriptKind(scriptKind: number): string { + switch (scriptKind) { + case 1: + return "javascript"; + case 2: + return "javascriptreact"; + case 4: + return "typescriptreact"; + case 6: + return "json"; + default: + return "typescript"; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts new file mode 100644 index 0000000000000..d17038759499d --- /dev/null +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; + +export interface ContentMapperTextRange { + readonly pos: number; + readonly end: number; +} + +export interface ContentMapperDiagnosticDirective { + readonly originalRange: ContentMapperTextRange; + readonly virtualRange: ContentMapperTextRange; + readonly policy: number; + readonly unusedCode: number; +} + +export interface ContentMapperVirtualSpan { + readonly generatedStart: number; + readonly generatedLength: number; + readonly originalStart: number; + readonly originalLength: number; + readonly kind: number; + readonly features: number; +} + +export interface ContentMapperVirtualFile { + readonly fileName: string; + readonly text: string; + readonly originalText: string; + readonly scriptKind: number; + readonly mappings: readonly ContentMapperVirtualSpan[]; + readonly diagnosticDirectives: readonly ContentMapperDiagnosticDirective[]; +} + +export interface MappedOutput extends ContentMapperVirtualFile { + readonly key: string; + readonly identity: string; +} + +export function toMappedOutputs(files: readonly ContentMapperVirtualFile[]): readonly MappedOutput[] { + return files.map((file, index) => ({ + ...file, + key: String(index), + identity: createHash("sha256").update(JSON.stringify(file)).digest("hex"), + })); +} diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts new file mode 100644 index 0000000000000..f68dbae9a678c --- /dev/null +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -0,0 +1,170 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import type { + ContentMapperDiagnosticDirective, + ContentMapperTextRange, + MappedOutput, +} from "./contentMapperVirtualFiles"; + +const revealDiagnosticDirectiveCommand = "typescript.native-preview.revealContentMapperDiagnosticDirective"; +const diagnosticDirectivesViewId = "typescript.native-preview.contentMapperDiagnosticDirectives"; + +interface OutputNode { + readonly kind: "output"; + readonly output: MappedOutput; +} + +export interface DiagnosticDirectiveNode { + readonly kind: "directive"; + readonly output: MappedOutput; + readonly directive: ContentMapperDiagnosticDirective; +} + +type DirectiveTreeNode = OutputNode | DiagnosticDirectiveNode; + +export class DiagnosticDirectivesView implements vscode.TreeDataProvider, vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly treeView: vscode.TreeView; + private readonly disposables: vscode.Disposable[] = []; + private sourceUri: vscode.Uri | undefined; + private outputs: readonly MappedOutput[] = []; + + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(reveal: (node: DiagnosticDirectiveNode) => void | Promise) { + this.treeView = vscode.window.createTreeView(diagnosticDirectivesViewId, { + treeDataProvider: this, + showCollapseAll: true, + }); + this.treeView.message = vscode.l10n.t("Open virtual documents to inspect diagnostic directives."); + const revealCommand = vscode.commands.registerCommand(revealDiagnosticDirectiveCommand, reveal); + this.treeView.onDidChangeVisibility(event => { + if (event.visible && this.sourceUri) { + this.changeEmitter.fire(undefined); + } + }); + this.disposables.push(this.changeEmitter, this.treeView, revealCommand); + } + + show(sourceUri: vscode.Uri, outputs: readonly MappedOutput[]): void { + this.sourceUri = sourceUri; + this.outputs = outputs; + this.updateMessage(); + this.changeEmitter.fire(undefined); + } + + refresh(sourceUri: vscode.Uri, outputs: readonly MappedOutput[] | undefined): void { + if (sourceUri.toString() !== this.sourceUri?.toString()) { + return; + } + this.outputs = outputs ?? []; + this.updateMessage(); + this.changeEmitter.fire(undefined); + } + + getTreeItem(node: DirectiveTreeNode): vscode.TreeItem { + if (node.kind === "output") { + const count = node.output.diagnosticDirectives.length; + const item = new vscode.TreeItem( + path.basename(node.output.fileName), + vscode.TreeItemCollapsibleState.Expanded, + ); + item.description = vscode.l10n.t("{0} {1}", count, count === 1 ? "directive" : "directives"); + item.iconPath = new vscode.ThemeIcon("file-code"); + return item; + } + + const { directive, output } = node; + const policy = diagnosticDirectivePolicyName(directive.policy); + const original = positionAt(output.originalText, directive.originalRange.pos); + const virtual = positionAt(output.text, directive.virtualRange.pos); + const item = new vscode.TreeItem(policy, vscode.TreeItemCollapsibleState.None); + item.description = `${formatPosition(original)} \u2192 ${formatPosition(virtual)}`; + item.iconPath = new vscode.ThemeIcon(directive.policy === 1 ? "error" : "eye"); + item.command = { + command: revealDiagnosticDirectiveCommand, + title: vscode.l10n.t("Reveal Diagnostic Directive"), + arguments: [node], + }; + item.tooltip = directiveTooltip(node); + return item; + } + + getChildren(node?: DirectiveTreeNode): DirectiveTreeNode[] { + if (!node) { + return this.outputs + .filter(output => output.diagnosticDirectives.length !== 0) + .map(output => ({ kind: "output", output })); + } + if (node.kind === "output") { + return node.output.diagnosticDirectives.map(directive => ({ + kind: "directive", + output: node.output, + directive, + })); + } + return []; + } + + dispose(): void { + for (const disposable of this.disposables.splice(0)) { + disposable.dispose(); + } + } + + private updateMessage(): void { + const directiveCount = this.outputs.reduce( + (count, output) => count + output.diagnosticDirectives.length, + 0, + ); + this.treeView.description = directiveCount === 0 ? undefined : String(directiveCount); + this.treeView.message = directiveCount === 0 + ? vscode.l10n.t("The current content-mapped file has no diagnostic directives.") + : undefined; + } +} + +function directiveTooltip(node: DiagnosticDirectiveNode): vscode.MarkdownString { + const { directive, output } = node; + const tooltip = new vscode.MarkdownString(); + tooltip.appendMarkdown(`**${diagnosticDirectivePolicyName(directive.policy)}** in \`${path.basename(output.fileName)}\``); + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Original range: {0}", formatRange(output.originalText, directive.originalRange))}`); + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Virtual range: {0}", formatRange(output.text, directive.virtualRange))}`); + if (directive.policy === 1) { + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Unused diagnostic code: {0}", directive.unusedCode)}`); + } + return tooltip; +} + +function diagnosticDirectivePolicyName(policy: number): string { + switch (policy) { + case 0: + return vscode.l10n.t("Ignore"); + case 1: + return vscode.l10n.t("Expect"); + default: + return vscode.l10n.t("Unknown ({0})", policy); + } +} + +function formatRange(text: string, range: ContentMapperTextRange): string { + return `${formatPosition(positionAt(text, range.pos))}\u2013${formatPosition(positionAt(text, range.end))}`; +} + +function formatPosition(position: vscode.Position): string { + return `${position.line + 1}:${position.character + 1}`; +} + +function positionAt(text: string, offset: number): vscode.Position { + const limit = Math.min(Math.max(offset, 0), text.length); + let line = 0; + let lineStart = 0; + for (let index = 0; index < limit; index++) { + if (text.charCodeAt(index) === 10) { + line++; + lineStart = index + 1; + } + } + return new vscode.Position(line, limit - lineStart); +} diff --git a/packages/vscode-typescript/src/extension.ts b/packages/vscode-typescript/src/extension.ts index 7bff6514e4397..c4fb72e226020 100644 --- a/packages/vscode-typescript/src/extension.ts +++ b/packages/vscode-typescript/src/extension.ts @@ -5,6 +5,7 @@ import { updateUseTsgoSetting, } from "./commands"; import type { ContentMapperContribution } from "./contentMapperContributions"; +import { registerContentMapperVirtualDocumentProvider } from "./contentMapperVirtualDocuments"; import { aiConnectionString, getExplicitConfigTarget, @@ -51,7 +52,10 @@ export async function activate(context: vscode.ExtensionContext): Promise sessionManager.stop()); let pluginWarningShown = false; diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 46303606b836f..83cded1f5e110 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -12,6 +12,7 @@ import { serializeContentMapperContributions, validateContentMapperRegistration, } from "./contentMapperContributions"; +import type { MappedOutput } from "./contentMapperVirtualFiles"; import { ProjectStatus } from "./projectStatus"; import { setupStatusBar } from "./statusBar"; import { TelemetryReporter } from "./telemetryReporting"; @@ -42,6 +43,8 @@ export class SessionManager implements vscode.Disposable { private lifecycleOperation = Promise.resolve(); private contentMapperSyncOperation = Promise.resolve(); + readonly onDidInitializeLanguageServer: vscode.Event; + constructor( context: vscode.ExtensionContext, outputChannel: vscode.LogOutputChannel, @@ -51,6 +54,7 @@ export class SessionManager implements vscode.Disposable { this.outputChannel = outputChannel; this.telemetryReporter = telemetryReporter; this.initializedEventEmitter = initializedEventEmitter; + this.onDidInitializeLanguageServer = initializedEventEmitter.event; this.disposables.push(vscode.workspace.onDidChangeConfiguration(event => { if (this.currentSession && event.affectsConfiguration("js/ts.contentMappers.enabled")) { @@ -114,6 +118,17 @@ export class SessionManager implements vscode.Disposable { return result.pipe; } + getContentMapperVirtualFiles(uri: vscode.Uri): Promise { + if (!this.currentSession) { + throw new Error(vscode.l10n.t("Language server is not running.")); + } + return this.currentSession.client.getContentMapperVirtualFiles(uri); + } + + isContentMapped(uri: vscode.Uri): Promise { + return this.currentSession?.client.isContentMapped(uri) ?? Promise.resolve(false); + } + registerContentMappers(contributorId: string, contributions: readonly ContentMapperContribution[]): vscode.Disposable { validateContentMapperRegistration(contributorId, contributions); if (this.contentMapperRegistrations.has(contributorId)) { diff --git a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts new file mode 100644 index 0000000000000..8e49f3b01b7d2 --- /dev/null +++ b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { toMappedOutputs } from "../src/contentMapperVirtualFiles"; + +test("adds stable output keys and identities to content mapper virtual files", () => { + const files = [{ + fileName: "/component.vue.ts", + text: "export {}", + originalText: "` + const box = `// 💥 +// @box-expect-error: unused +const café = 1;` files := map[string]string{ "/home/project/tsconfig.json": `{ "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true }, - "contentMappers": [ { "package": "mapper", "extensions": [".vue"] } ] + "contentMappers": [ + { "package": "mapper", "extensions": [".vue"] }, + { "package": "box-mapper", "extensions": [".box"] } + ] }`, - "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), - "/home/project/ProfileCard.vue": component, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.ComponentMapper), + "/home/project/node_modules/box-mapper/package.json": strings.Replace(contentmappertest.PackageJSON(contentmappertest.TransformingMapper), `"name": "mapper"`, `"name": "box-mapper"`, 1), + "/home/project/ProfileCard.vue": component, + "/home/project/example.box": box, } var mu sync.Mutex @@ -156,14 +165,24 @@ export const title = "Profile"; if registration.Id == "content-mapper-did-open" { assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentDidOpen != nil) selector := registration.RegisterOptions.TextDocumentDidOpen.DocumentSelector.DocumentSelector - assert.Assert(t, selector != nil && len(*selector) == 1) - assert.Equal(t, *(*selector)[0].Pattern.Pattern.Pattern, "**/*.vue") + assert.Assert(t, selector != nil && len(*selector) == 2) + patterns := map[string]bool{} + for _, filter := range *selector { + patterns[*filter.Pattern.Pattern.Pattern] = true + } + assert.Assert(t, patterns["**/*.vue"]) + assert.Assert(t, patterns["**/*.box"]) } if registration.Id == "content-mapper-semantic-tokens" { assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentSemanticTokens != nil) selector := registration.RegisterOptions.TextDocumentSemanticTokens.DocumentSelector.DocumentSelector - assert.Assert(t, selector != nil && len(*selector) == 1) - assert.Equal(t, *(*selector)[0].Pattern.Pattern.Pattern, "**/*.vue") + assert.Assert(t, selector != nil && len(*selector) == 2) + patterns := map[string]bool{} + for _, filter := range *selector { + patterns[*filter.Pattern.Pattern.Pattern] = true + } + assert.Assert(t, patterns["**/*.vue"]) + assert.Assert(t, patterns["**/*.box"]) } if registration.Id == "content-mapper-code-action" { assert.Assert(t, registration.RegisterOptions != nil && registration.RegisterOptions.TextDocumentCodeAction != nil) @@ -202,6 +221,34 @@ export const title = "Profile"; assert.Equal(t, virtualFiles.Files[0].Mappings[0].Kind, int32(0)) assert.Equal(t, virtualFiles.Files[0].Mappings[0].Features, int32((1<<20)-1)) + boxURI := lsproto.DocumentUri("file:///home/project/example.box") + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{Uri: boxURI, LanguageId: "box", Version: 1, Text: box}, + }) + _, boxVirtualFiles, ok := lsptestutil.SendRequest(t, client, lsproto.CustomContentMapperVirtualFilesInfo, &lsproto.ContentMapperVirtualFilesParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: boxURI}, + }) + assert.Assert(t, ok) + assert.Equal(t, len(boxVirtualFiles.Files), 1) + boxVirtualFile := boxVirtualFiles.Files[0] + assert.Equal(t, len(boxVirtualFile.Mappings), 1) + mappedTextStart := strings.Index(boxVirtualFile.Text, "// 💥") + assert.Assert(t, mappedTextStart >= 0) + assert.Equal(t, boxVirtualFile.Mappings[0].GeneratedStart, int32(utf16Length(boxVirtualFile.Text[:mappedTextStart]))) + assert.Equal(t, boxVirtualFile.Mappings[0].GeneratedLength, int32(utf16Length(box))) + assert.Equal(t, boxVirtualFile.Mappings[0].OriginalLength, int32(utf16Length(box))) + assert.Equal(t, len(boxVirtualFile.DiagnosticDirectives), 1) + directive := boxVirtualFile.DiagnosticDirectives[0] + directiveStart := strings.Index(box, "// @box-expect-error") + directiveEnd := directiveStart + strings.IndexByte(box[directiveStart:], '\n') + assert.Equal(t, directive.OriginalRange.Pos, int32(utf16Length(box[:directiveStart]))) + assert.Equal(t, directive.OriginalRange.End, int32(utf16Length(box[:directiveEnd]))) + affectedText := "const café = 1;" + affectedStart := strings.Index(boxVirtualFile.Text, affectedText) + assert.Assert(t, affectedStart >= 0) + assert.Equal(t, directive.VirtualRange.Pos, int32(utf16Length(boxVirtualFile.Text[:affectedStart]))) + assert.Equal(t, directive.VirtualRange.End, int32(utf16Length(boxVirtualFile.Text[:affectedStart+len(affectedText)]))) + assert.NilError(t, fs.WriteFile("/home/project/tsconfig.json", `{ "compilerOptions": { "target": "es2020", "module": "esnext", "moduleResolution": "bundler", "strict": true } }`)) @@ -273,4 +320,11 @@ export const title = "Profile"; lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, }) + lsptestutil.SendNotification(t, client, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: boxURI}, + }) +} + +func utf16Length(text string) int { + return len(utf16.Encode([]rune(text))) } From 0e582497deca3f1472394d303a88bea0c4e0d46b Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Tue, 8 Sep 2026 09:17:37 -0700 Subject: [PATCH 03/11] PR feedback --- .../src/contentMapperVirtualDocuments.ts | 27 +++++++++++++------ .../src/contentMapperVirtualFiles.ts | 2 +- .../src/diagnosticDirectivesView.ts | 9 ++++--- tsc/internal/ast/ast.go | 1 + tsc/internal/contentmapper/host_test.go | 5 +++- tsc/internal/contentmapper/hostimpl.go | 1 + .../lsp/lsproto/_generate/generate.mts | 2 +- tsc/internal/lsp/lsproto/lsp_generated.go | 2 +- tsc/internal/lsp/server.go | 10 ++++--- tsc/internal/lsp/server_contentmapper_test.go | 18 +++++++++++++ 10 files changed, 59 insertions(+), 18 deletions(-) diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts index 17fcfe0d7bf66..60d4bc6e3221e 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -10,6 +10,7 @@ import { type DiagnosticDirectiveNode, DiagnosticDirectivesView, } from "./diagnosticDirectivesView"; +import { readNativePreviewConfig } from "./util"; const virtualDocumentScheme = "typescript-content-mapper"; const activeEditorIsContentMappedContext = "typescript.native-preview.activeEditorIsContentMapped"; @@ -116,6 +117,14 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } }), vscode.window.onDidChangeVisibleTextEditors(() => this.scheduleInspection()), + vscode.workspace.onDidChangeConfiguration(event => { + if ( + event.affectsConfiguration("js/ts.showDebugInfo") + || event.affectsConfiguration("typescript.native-preview.showDebugInfo") + ) { + this.updateActiveEditorContext(vscode.window.activeTextEditor); + } + }), provider.onDidInitializeLanguageServer(() => { this.updateActiveEditorContext(vscode.window.activeTextEditor); for (const source of this.sourceToVirtualUris.keys()) { @@ -283,7 +292,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, private async updateActiveEditorContextNow(editor: vscode.TextEditor | undefined, version: number): Promise { let isContentMapped = false; - if (editor?.document.uri.scheme === "file") { + if (readNativePreviewConfig("showDebugInfo", false) && editor?.document.uri.scheme === "file") { try { isContentMapped = await this.provider.isContentMapped(editor.document.uri); } @@ -404,13 +413,15 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, throw new Error(`Could not load virtual document "${node.output.fileName}".`); } - const sourceDocument = await vscode.workspace.openTextDocument(sourceUri); - const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); - await vscode.window.showTextDocument(sourceDocument, { - preserveFocus: true, - preview: false, - selection: sourceRange, - }); + if (node.directive.originalRange) { + const sourceDocument = await vscode.workspace.openTextDocument(sourceUri); + const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); + await vscode.window.showTextDocument(sourceDocument, { + preserveFocus: true, + preview: false, + selection: sourceRange, + }); + } let virtualDocument = await vscode.workspace.openTextDocument(virtualUri); virtualDocument = await vscode.languages.setTextDocumentLanguage( diff --git a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts index d17038759499d..176293656e9c6 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -6,7 +6,7 @@ export interface ContentMapperTextRange { } export interface ContentMapperDiagnosticDirective { - readonly originalRange: ContentMapperTextRange; + readonly originalRange?: ContentMapperTextRange; readonly virtualRange: ContentMapperTextRange; readonly policy: number; readonly unusedCode: number; diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts index 6d5d932b92c56..687d3b79c6104 100644 --- a/packages/vscode-typescript/src/diagnosticDirectivesView.ts +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -79,10 +79,11 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider Date: Tue, 8 Sep 2026 09:28:22 -0700 Subject: [PATCH 04/11] Revert incorrect feedback suggestion --- .../src/contentMapperVirtualDocuments.ts | 32 +++++++++++-------- .../src/contentMapperVirtualFiles.ts | 2 +- .../src/diagnosticDirectivesView.ts | 9 ++---- tsc/internal/ast/ast.go | 1 - tsc/internal/contentmapper/host_test.go | 5 +-- tsc/internal/contentmapper/hostimpl.go | 1 - .../lsp/lsproto/_generate/generate.mts | 2 +- tsc/internal/lsp/lsproto/lsp_generated.go | 2 +- tsc/internal/lsp/server.go | 10 ++---- tsc/internal/lsp/server_contentmapper_test.go | 3 +- 10 files changed, 31 insertions(+), 36 deletions(-) diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts index 60d4bc6e3221e..0aafbd2219f19 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -413,15 +413,13 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, throw new Error(`Could not load virtual document "${node.output.fileName}".`); } - if (node.directive.originalRange) { - const sourceDocument = await vscode.workspace.openTextDocument(sourceUri); - const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); - await vscode.window.showTextDocument(sourceDocument, { - preserveFocus: true, - preview: false, - selection: sourceRange, - }); - } + const sourceDocument = await vscode.workspace.openTextDocument(sourceUri); + const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); + await vscode.window.showTextDocument(sourceDocument, { + preserveFocus: true, + preview: false, + selection: sourceRange.isEmpty ? undefined : sourceRange, + }); let virtualDocument = await vscode.workspace.openTextDocument(virtualUri); virtualDocument = await vscode.languages.setTextDocumentLanguage( @@ -524,8 +522,12 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, const virtualRanges: vscode.Range[][] = [[], [], []]; for (const mapping of mappings) { const kind = normalizedMappingKind(mapping.kind); - sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); - virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + if (mapping.originalLength !== 0) { + sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); + } + if (mapping.generatedLength !== 0) { + virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + } } for (let kind = 0; kind < this.mappingDecorations.length; kind++) { virtualEditor.setDecorations(this.mappingDecorations[kind]!, virtualRanges[kind]!); @@ -554,8 +556,12 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, const virtualRanges: vscode.Range[][] = [[], [], []]; for (const mapping of mappings) { const kind = normalizedMappingKind(mapping.kind); - sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); - virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + if (mapping.originalLength !== 0) { + sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); + } + if (mapping.generatedLength !== 0) { + virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); + } } for (let kind = 0; kind < this.mappingDecorations.length; kind++) { sourceEditor.setDecorations(this.mappingDecorations[kind]!, sourceRanges[kind]!); diff --git a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts index 176293656e9c6..d17038759499d 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -6,7 +6,7 @@ export interface ContentMapperTextRange { } export interface ContentMapperDiagnosticDirective { - readonly originalRange?: ContentMapperTextRange; + readonly originalRange: ContentMapperTextRange; readonly virtualRange: ContentMapperTextRange; readonly policy: number; readonly unusedCode: number; diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts index 687d3b79c6104..6d5d932b92c56 100644 --- a/packages/vscode-typescript/src/diagnosticDirectivesView.ts +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -79,11 +79,10 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider Date: Wed, 9 Sep 2026 10:20:44 -0700 Subject: [PATCH 05/11] Fix initialization issues and diagnostic directive UX --- .../vscode-typescript/l10n/bundle.l10n.json | 1 + packages/vscode-typescript/package.json | 3 +- .../src/contentMapperVirtualDocuments.ts | 40 ++++++- .../src/contentMapperVirtualFiles.ts | 13 +++ .../src/diagnosticDirectivesView.ts | 105 +++++++++++++++--- packages/vscode-typescript/src/session.ts | 5 +- .../test/contentMapperVirtualFiles.test.ts | 21 +++- 7 files changed, 163 insertions(+), 25 deletions(-) diff --git a/packages/vscode-typescript/l10n/bundle.l10n.json b/packages/vscode-typescript/l10n/bundle.l10n.json index 416e09129368d..a3d8227d3f30c 100644 --- a/packages/vscode-typescript/l10n/bundle.l10n.json +++ b/packages/vscode-typescript/l10n/bundle.l10n.json @@ -72,6 +72,7 @@ "The current content-mapped file has no diagnostic directives.": "The current content-mapped file has no diagnostic directives.", "Original range: {0}": "Original range: {0}", "Virtual range: {0}": "Virtual range: {0}", + "Preview:": "Preview:", "Unused diagnostic code: {0}": "Unused diagnostic code: {0}", "Expect": "Expect", "Unknown ({0})": "Unknown ({0})", diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index a5ab6e53276b2..502f39dac74fc 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -32,7 +32,8 @@ "onLanguage:javascript", "onLanguage:javascriptreact", "onLanguage:typescript", - "onLanguage:typescriptreact" + "onLanguage:typescriptreact", + "onFileSystem:typescript-content-mapper" ], "contributes": { "configuration": [ diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts index 0aafbd2219f19..2d71ead141141 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -211,6 +211,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } const outputs = await this.loadOutputs(parsed.sourceUri); this.remember(parsed.sourceUri, outputs); + this.diagnosticDirectivesView.show(parsed.sourceUri, outputs); return this.entries.get(uri.toString()); } @@ -413,12 +414,28 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, throw new Error(`Could not load virtual document "${node.output.fileName}".`); } + const sourceEditor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === sourceUri.toString(), + ); + const virtualEditor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === virtualUri.toString(), + ); + const inspectorEditor = virtualEditor ?? vscode.window.visibleTextEditors.find(editor => { + if (editor.document.uri.scheme !== virtualDocumentScheme) { + return false; + } + return parseVirtualUri(editor.document.uri)?.sourceUri.toString() === sourceUri.toString(); + }); + const targetColumn = inspectorEditor?.viewColumn + ?? (sourceEditor?.viewColumn === undefined ? vscode.ViewColumn.Beside : sourceEditor.viewColumn + 1); + const sourceDocument = await vscode.workspace.openTextDocument(sourceUri); const sourceRange = rangeFromTextRange(sourceDocument, node.directive.originalRange); await vscode.window.showTextDocument(sourceDocument, { preserveFocus: true, preview: false, selection: sourceRange.isEmpty ? undefined : sourceRange, + viewColumn: sourceEditor?.viewColumn, }); let virtualDocument = await vscode.workspace.openTextDocument(virtualUri); @@ -427,14 +444,19 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, languageIdForScriptKind(entry.output.scriptKind), ); const virtualRange = rangeFromTextRange(virtualDocument, node.directive.virtualRange); - const virtualEditor = await vscode.window.showTextDocument(virtualDocument, { + const revealedVirtualEditor = await vscode.window.showTextDocument(virtualDocument, { preserveFocus: false, preview: false, - selection: virtualRange, - viewColumn: vscode.ViewColumn.Beside, + selection: virtualRange.isEmpty ? undefined : virtualRange, + viewColumn: targetColumn, }); - virtualEditor.revealRange(virtualRange, vscode.TextEditorRevealType.InCenterIfOutsideViewport); - this.scheduleInspection(); + if (virtualRange.isEmpty) { + this.clearInspection(); + } + else { + revealedVirtualEditor.revealRange(virtualRange, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + this.scheduleInspection(); + } } private sourceUriForOutput(output: MappedOutput): vscode.Uri | undefined { @@ -481,6 +503,10 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, this.clearInspection(); return; } + const offset = virtualEditor.document.offsetAt(virtualEditor.selection.active); + void this.diagnosticDirectivesView.revealVirtualRange(entry.sourceUri, entry.output, offset).catch(error => { + this.output.debug(`Could not reveal the selected virtual diagnostic directive: ${String(error)}`); + }); const sourceEditor = vscode.window.visibleTextEditors.find( candidate => candidate.document.uri.toString() === entry.sourceUri.toString(), ); @@ -488,7 +514,6 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, this.clearInspection(); return; } - const offset = virtualEditor.document.offsetAt(virtualEditor.selection.active); const mappings = entry.output.mappings.filter( mapping => containsOffset(mapping.generatedStart, mapping.generatedLength, offset), ); @@ -503,6 +528,9 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, return; } const offset = sourceEditor.document.offsetAt(sourceEditor.selection.active); + void this.diagnosticDirectivesView.revealOriginalRange(sourceEditor.document.uri, offset).catch(error => { + this.output.debug(`Could not reveal the selected original diagnostic directive: ${String(error)}`); + }); this.clearInspection(); const sourceRanges: vscode.Range[][] = [[], [], []]; for (const virtualUri of virtualUris) { diff --git a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts index d17038759499d..3ce53e72a26dc 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -35,6 +35,19 @@ export interface MappedOutput extends ContentMapperVirtualFile { readonly identity: string; } +export function containsNonEmptyTextRange(range: ContentMapperTextRange, offset: number): boolean { + return range.pos < range.end && range.pos <= offset && offset < range.end; +} + +export function textRangePreview(text: string, range: ContentMapperTextRange, maxLength = 80): string { + const preview = text.slice(range.pos, range.end).trim().replace(/\s+/g, " "); + const characters = [...preview]; + if (characters.length <= maxLength) { + return preview; + } + return `${characters.slice(0, Math.max(0, maxLength - 3)).join("")}...`; +} + export function toMappedOutputs(files: readonly ContentMapperVirtualFile[]): readonly MappedOutput[] { return files.map((file, index) => ({ ...file, diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts index 6d5d932b92c56..edcc768ed89d3 100644 --- a/packages/vscode-typescript/src/diagnosticDirectivesView.ts +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -6,6 +6,10 @@ import type { ContentMapperTextRange, MappedOutput, } from "./contentMapperVirtualFiles"; +import { + containsNonEmptyTextRange, + textRangePreview, +} from "./contentMapperVirtualFiles"; const revealDiagnosticDirectiveCommand = "typescript.native-preview.revealContentMapperDiagnosticDirective"; const diagnosticDirectivesViewId = "typescript.native-preview.contentMapperDiagnosticDirectives"; @@ -29,6 +33,9 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider(); + private selectedDirective: DiagnosticDirectiveNode | undefined; readonly onDidChangeTreeData = this.changeEmitter.event; @@ -49,7 +56,7 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider output.diagnosticDirectives.length !== 0) - .map(output => ({ kind: "output", output })); + return [...this.outputNodes]; } if (node.kind === "output") { - return node.output.diagnosticDirectives.map(directive => ({ - kind: "directive", - output: node.output, - directive, - })); + return [...(this.directiveNodes.get(node.output) ?? [])]; } return []; } + getParent(node: DirectiveTreeNode): OutputNode | undefined { + if (node.kind === "directive") { + return this.outputNodes.find(outputNode => outputNode.output === node.output); + } + return undefined; + } + + revealOriginalRange(sourceUri: vscode.Uri, offset: number): Promise { + if (sourceUri.toString() !== this.sourceUri?.toString()) { + return Promise.resolve(); + } + return this.revealMatchingDirective( + this.outputNodes.flatMap(node => this.directiveNodes.get(node.output) ?? []), + node => containsNonEmptyTextRange(node.directive.originalRange, offset), + ); + } + + revealVirtualRange(sourceUri: vscode.Uri, output: MappedOutput, offset: number): Promise { + if (sourceUri.toString() !== this.sourceUri?.toString()) { + return Promise.resolve(); + } + return this.revealMatchingDirective( + this.directiveNodes.get(output) ?? [], + node => containsNonEmptyTextRange(node.directive.virtualRange, offset), + ); + } + dispose(): void { for (const disposable of this.disposables.splice(0)) { disposable.dispose(); @@ -125,14 +156,54 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider output.diagnosticDirectives.length !== 0) + .map(output => ({ kind: "output", output })); + this.directiveNodes = new Map(this.outputNodes.map(({ output }) => [ + output, + output.diagnosticDirectives.map(directive => ({ + kind: "directive", + output, + directive, + })), + ])); + this.selectedDirective = undefined; + } + + private revealMatchingDirective( + nodes: readonly DiagnosticDirectiveNode[], + matches: (node: DiagnosticDirectiveNode) => boolean, + ): Promise { + const node = nodes.find(matches); + if (!node || node === this.selectedDirective) { + this.selectedDirective = node; + return Promise.resolve(); + } + this.selectedDirective = node; + return Promise.resolve(this.treeView.reveal(node, { + select: true, + focus: false, + expand: true, + })); + } } function directiveTooltip(node: DiagnosticDirectiveNode): vscode.MarkdownString { const { directive, output } = node; const tooltip = new vscode.MarkdownString(); tooltip.appendMarkdown(`**${diagnosticDirectivePolicyName(directive.policy)}** in \`${path.basename(output.fileName)}\``); - tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Original range: {0}", formatRange(output.originalText, directive.originalRange))}`); + if (!isEmptyRange(directive.originalRange)) { + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Original range: {0}", formatRange(output.originalText, directive.originalRange))}`); + } tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Virtual range: {0}", formatRange(output.text, directive.virtualRange))}`); + const preview = textRangePreview(output.text, directive.virtualRange); + if (preview) { + tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Preview:")}`); + tooltip.appendCodeblock(preview); + } if (directive.policy === 1) { tooltip.appendMarkdown(`\n\n${vscode.l10n.t("Unused diagnostic code: {0}", directive.unusedCode)}`); } @@ -154,6 +225,10 @@ function formatRange(text: string, range: ContentMapperTextRange): string { return `${formatPosition(positionAt(text, range.pos))}\u2013${formatPosition(positionAt(text, range.end))}`; } +function isEmptyRange(range: ContentMapperTextRange): boolean { + return range.pos === range.end; +} + function formatPosition(position: vscode.Position): string { return `${position.line + 1}:${position.character + 1}`; } diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 2d6eaa6678f33..4beb35f80bb71 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -121,8 +121,9 @@ export class SessionManager implements vscode.Disposable { return result.pipe; } - getContentMapperVirtualFiles(uri: vscode.Uri): Promise { - if (!this.currentSession) { + async getContentMapperVirtualFiles(uri: vscode.Uri): Promise { + await this.lifecycleOperation; + if (!this.currentSession?.client.isInitialized) { throw new Error(vscode.l10n.t("Language server is not running.")); } return this.currentSession.client.getContentMapperVirtualFiles(uri); diff --git a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts index 8e49f3b01b7d2..7ebf030e6ddc6 100644 --- a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts +++ b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts @@ -1,7 +1,26 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { toMappedOutputs } from "../src/contentMapperVirtualFiles"; +import { + containsNonEmptyTextRange, + textRangePreview, + toMappedOutputs, +} from "../src/contentMapperVirtualFiles"; + +test("matches offsets only inside non-empty text ranges", () => { + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 5 }, 2), true); + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 5 }, 4), true); + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 5 }, 5), false); + assert.equal(containsNonEmptyTextRange({ pos: 2, end: 2 }, 2), false); +}); + +test("previews virtual ranges with normalized whitespace and safe truncation", () => { + assert.equal( + textRangePreview("before first\n second after", { pos: 6, end: 23 }), + "first second", + ); + assert.equal(textRangePreview("A😀BCDE", { pos: 0, end: 7 }, 5), "A😀..."); +}); test("adds stable output keys and identities to content mapper virtual files", () => { const files = [{ From 3bb90932dd3b5ab5ab70e17a61dcd7eabcd75774 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 9 Sep 2026 10:48:36 -0700 Subject: [PATCH 06/11] PR feedback --- .../src/contentMapperVirtualDocuments.ts | 89 +++++++++++++++++-- .../src/diagnosticDirectivesView.ts | 11 +++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts index 2d71ead141141..c09baa1d20cf5 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -41,6 +41,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, private readonly entries = new Map(); private readonly sourceToVirtualUris = new Map(); private readonly refreshTimers = new Map(); + private readonly evictionTimers = new Map(); private inspectionTimer: NodeJS.Timeout | undefined; private activeEditorContextVersion = 0; private readonly mappingDecorations = [ @@ -96,6 +97,11 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } }), vscode.workspace.onDidSaveTextDocument(document => this.scheduleRefresh(document.uri)), + vscode.workspace.onDidCloseTextDocument(document => { + if (document.uri.scheme === virtualDocumentScheme) { + this.scheduleSourceEviction(document.uri); + } + }), vscode.languages.onDidChangeDiagnostics(event => { for (const uri of event.uris) { this.scheduleRefresh(uri); @@ -103,11 +109,9 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, }), vscode.window.onDidChangeActiveTextEditor(editor => { this.updateActiveEditorContext(editor); - if (editor?.document.uri.scheme === virtualDocumentScheme) { - const entry = this.entries.get(editor.document.uri.toString()); - if (entry) { - this.refreshSource(entry.sourceUri); - } + const inspectedSource = this.showDiagnosticDirectivesForEditor(editor); + if (inspectedSource) { + this.refreshSource(inspectedSource); } this.scheduleInspection(); }), @@ -191,6 +195,10 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, clearTimeout(timer); } this.refreshTimers.clear(); + for (const timer of this.evictionTimers.values()) { + clearTimeout(timer); + } + this.evictionTimers.clear(); if (this.inspectionTimer) { clearTimeout(this.inspectionTimer); this.inspectionTimer = undefined; @@ -331,6 +339,72 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, return nextUris; } + private showDiagnosticDirectivesForEditor(editor: vscode.TextEditor | undefined): vscode.Uri | undefined { + if (!editor) { + this.diagnosticDirectivesView.clear(); + return undefined; + } + const sourceUri = editor.document.uri.scheme === virtualDocumentScheme + ? this.entries.get(editor.document.uri.toString())?.sourceUri + : this.sourceToVirtualUris.has(editor.document.uri.toString()) ? editor.document.uri : undefined; + if (!sourceUri) { + this.diagnosticDirectivesView.clear(); + return undefined; + } + const outputs = (this.sourceToVirtualUris.get(sourceUri.toString()) ?? []) + .map(uri => this.entries.get(uri.toString())?.output) + .filter(output => output !== undefined); + this.diagnosticDirectivesView.show(sourceUri, outputs); + return sourceUri; + } + + private scheduleSourceEviction(closedVirtualUri: vscode.Uri): void { + const entry = this.entries.get(closedVirtualUri.toString()); + const parsed = entry ? undefined : parseVirtualUri(closedVirtualUri); + const sourceUri = entry?.sourceUri ?? parsed?.sourceUri; + if (!sourceUri) { + return; + } + const sourceKey = sourceUri.toString(); + const existing = this.evictionTimers.get(sourceKey); + if (existing) { + clearTimeout(existing); + } + this.evictionTimers.set( + sourceKey, + setTimeout(() => { + this.evictionTimers.delete(sourceKey); + this.evictSourceIfUnused(sourceUri); + }, 0), + ); + } + + private evictSourceIfUnused(sourceUri: vscode.Uri): void { + const sourceKey = sourceUri.toString(); + const virtualUris = this.sourceToVirtualUris.get(sourceKey); + if (!virtualUris) { + return; + } + const virtualKeys = new Set(virtualUris.map(uri => uri.toString())); + const hasOpenVirtualDocument = vscode.workspace.textDocuments.some(document => virtualKeys.has(document.uri.toString())); + if (hasOpenVirtualDocument) { + return; + } + const refreshTimer = this.refreshTimers.get(sourceKey); + if (refreshTimer) { + clearTimeout(refreshTimer); + this.refreshTimers.delete(sourceKey); + } + this.sourceToVirtualUris.delete(sourceKey); + for (const uri of virtualUris) { + const key = uri.toString(); + this.entries.delete(key); + this.highlightedMappings.delete(key); + } + this.diagnosticDirectivesView.clear(sourceUri); + this.scheduleInspection(); + } + private scheduleRefresh(sourceUri: vscode.Uri): void { const sourceKey = sourceUri.toString(); if (!this.sourceToVirtualUris.has(sourceKey)) { @@ -363,8 +437,10 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } const outputs = await this.loadOutputs(sourceUri); + if (this.sourceToVirtualUris.get(sourceKey) !== previousUris) { + return; + } if (outputs.length === 0) { - this.clearInspection(); this.diagnosticDirectivesView.refresh(sourceUri, undefined); this.sourceToVirtualUris.delete(sourceKey); const changes = previousUris.map(uri => ({ type: vscode.FileChangeType.Deleted, uri })); @@ -372,6 +448,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, this.entries.delete(uri.toString()); } this.changeEmitter.fire(changes); + this.scheduleInspection(); return; } diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts index edcc768ed89d3..6aaed568e27a6 100644 --- a/packages/vscode-typescript/src/diagnosticDirectivesView.ts +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -70,6 +70,17 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider Date: Wed, 9 Sep 2026 11:10:07 -0700 Subject: [PATCH 07/11] PR feedback --- .../src/contentMapperVirtualDocuments.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts index c09baa1d20cf5..e6a53b9e3670e 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -43,6 +43,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, private readonly refreshTimers = new Map(); private readonly evictionTimers = new Map(); private inspectionTimer: NodeJS.Timeout | undefined; + private activeEditorContextTimer: NodeJS.Timeout | undefined; private activeEditorContextVersion = 0; private readonly mappingDecorations = [ vscode.window.createTextEditorDecorationType({ @@ -94,6 +95,9 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } else { this.scheduleRefresh(event.document.uri); + if (event.document === vscode.window.activeTextEditor?.document) { + this.scheduleActiveEditorContextUpdate(event.document); + } } }), vscode.workspace.onDidSaveTextDocument(document => this.scheduleRefresh(document.uri)), @@ -203,6 +207,10 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, clearTimeout(this.inspectionTimer); this.inspectionTimer = undefined; } + if (this.activeEditorContextTimer) { + clearTimeout(this.activeEditorContextTimer); + this.activeEditorContextTimer = undefined; + } for (const disposable of this.disposables.splice(0)) { disposable.dispose(); } @@ -293,12 +301,29 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } private updateActiveEditorContext(editor: vscode.TextEditor | undefined): void { + if (this.activeEditorContextTimer) { + clearTimeout(this.activeEditorContextTimer); + this.activeEditorContextTimer = undefined; + } const version = ++this.activeEditorContextVersion; void this.updateActiveEditorContextNow(editor, version).catch(error => { this.output.error(`Could not update the active content mapper context: ${String(error)}`); }); } + private scheduleActiveEditorContextUpdate(document: vscode.TextDocument): void { + if (this.activeEditorContextTimer) { + clearTimeout(this.activeEditorContextTimer); + } + this.activeEditorContextTimer = setTimeout(() => { + this.activeEditorContextTimer = undefined; + const editor = vscode.window.activeTextEditor; + if (editor?.document === document) { + this.updateActiveEditorContext(editor); + } + }, 100); + } + private async updateActiveEditorContextNow(editor: vscode.TextEditor | undefined, version: number): Promise { let isContentMapped = false; if (readNativePreviewConfig("showDebugInfo", false) && editor?.document.uri.scheme === "file") { From 2fef2fb3b0f34002c4988b11b60133ad806d6370 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 9 Sep 2026 11:24:03 -0700 Subject: [PATCH 08/11] Remove duplicate setting check --- packages/vscode-typescript/package.json | 8 ++++---- .../src/contentMapperVirtualDocuments.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 502f39dac74fc..814511b79c938 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -231,7 +231,7 @@ "id": "typescript.native-preview.contentMapperDiagnosticDirectives", "name": "%native-preview.contentMapperDiagnosticDirectives.name%", "icon": "$(list-tree)", - "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo" + "when": "typescript.native-preview.serverRunning && typescript.native-preview.contentMapperInspectorEnabled" } ] }, @@ -281,7 +281,7 @@ { "command": "typescript.native-preview.showContentMapperVirtualDocuments", "title": "%native-preview.showContentMapperVirtualDocuments.title%", - "enablement": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && typescript.native-preview.activeEditorIsContentMapped", + "enablement": "typescript.native-preview.serverRunning && typescript.native-preview.activeEditorIsContentMapped", "category": "TypeScript" }, { @@ -358,7 +358,7 @@ }, { "command": "typescript.native-preview.showContentMapperVirtualDocuments", - "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && typescript.native-preview.activeEditorIsContentMapped" + "when": "typescript.native-preview.serverRunning && typescript.native-preview.activeEditorIsContentMapped" }, { "command": "typescript.native-preview.sortImports", @@ -379,7 +379,7 @@ "editor/title/context": [ { "command": "typescript.native-preview.showContentMapperVirtualDocuments", - "when": "typescript.native-preview.serverRunning && config.js/ts.showDebugInfo && typescript.native-preview.activeEditorIsContentMapped", + "when": "typescript.native-preview.serverRunning && typescript.native-preview.activeEditorIsContentMapped", "group": "navigation@1.5" } ] diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts index e6a53b9e3670e..fbe501943d0dc 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -14,6 +14,7 @@ import { readNativePreviewConfig } from "./util"; const virtualDocumentScheme = "typescript-content-mapper"; const activeEditorIsContentMappedContext = "typescript.native-preview.activeEditorIsContentMapped"; +const contentMapperInspectorEnabledContext = "typescript.native-preview.contentMapperInspectorEnabled"; export const showVirtualDocumentsCommand = "typescript.native-preview.showContentMapperVirtualDocuments"; export interface ContentMapperVirtualFilesProvider { @@ -130,6 +131,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, event.affectsConfiguration("js/ts.showDebugInfo") || event.affectsConfiguration("typescript.native-preview.showDebugInfo") ) { + this.updateInspectorEnabledContext(); this.updateActiveEditorContext(vscode.window.activeTextEditor); } }), @@ -143,6 +145,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, this.updateActiveEditorContext(vscode.window.activeTextEditor); }), ]; + this.updateInspectorEnabledContext(); this.updateActiveEditorContext(vscode.window.activeTextEditor); } @@ -195,6 +198,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, dispose(): void { this.activeEditorContextVersion++; void vscode.commands.executeCommand("setContext", activeEditorIsContentMappedContext, false); + void vscode.commands.executeCommand("setContext", contentMapperInspectorEnabledContext, false); for (const timer of this.refreshTimers.values()) { clearTimeout(timer); } @@ -300,6 +304,14 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, return this.provider.getContentMapperVirtualFiles(sourceUri); } + private updateInspectorEnabledContext(): void { + void vscode.commands.executeCommand( + "setContext", + contentMapperInspectorEnabledContext, + readNativePreviewConfig("showDebugInfo", false), + ); + } + private updateActiveEditorContext(editor: vscode.TextEditor | undefined): void { if (this.activeEditorContextTimer) { clearTimeout(this.activeEditorContextTimer); From a9e185de17d0e4cdf517cd061d6a835ba8ed21a6 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 9 Sep 2026 11:26:32 -0700 Subject: [PATCH 09/11] PR feedback --- .../src/contentMapperVirtualFiles.ts | 21 +++++++++++++++++++ .../src/diagnosticDirectivesView.ts | 13 +++--------- .../test/contentMapperVirtualFiles.test.ts | 8 +++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts index 3ce53e72a26dc..f9e7322b92511 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -48,6 +48,27 @@ export function textRangePreview(text: string, range: ContentMapperTextRange, ma return `${characters.slice(0, Math.max(0, maxLength - 3)).join("")}...`; } +export function textPositionAt(text: string, offset: number): { readonly line: number; readonly character: number; } { + const limit = Math.min(Math.max(offset, 0), text.length); + let line = 0; + let lineStart = 0; + for (let index = 0; index < limit; index++) { + const character = text.charCodeAt(index); + if (character === 13) { + if (index + 1 < limit && text.charCodeAt(index + 1) === 10) { + index++; + } + line++; + lineStart = index + 1; + } + else if (character === 10) { + line++; + lineStart = index + 1; + } + } + return { line, character: limit - lineStart }; +} + export function toMappedOutputs(files: readonly ContentMapperVirtualFile[]): readonly MappedOutput[] { return files.map((file, index) => ({ ...file, diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts index 6aaed568e27a6..824f8f973d3df 100644 --- a/packages/vscode-typescript/src/diagnosticDirectivesView.ts +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -8,6 +8,7 @@ import type { } from "./contentMapperVirtualFiles"; import { containsNonEmptyTextRange, + textPositionAt, textRangePreview, } from "./contentMapperVirtualFiles"; @@ -245,14 +246,6 @@ function formatPosition(position: vscode.Position): string { } function positionAt(text: string, offset: number): vscode.Position { - const limit = Math.min(Math.max(offset, 0), text.length); - let line = 0; - let lineStart = 0; - for (let index = 0; index < limit; index++) { - if (text.charCodeAt(index) === 10) { - line++; - lineStart = index + 1; - } - } - return new vscode.Position(line, limit - lineStart); + const position = textPositionAt(text, offset); + return new vscode.Position(position.line, position.character); } diff --git a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts index 7ebf030e6ddc6..b3d02e885ff3d 100644 --- a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts +++ b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { containsNonEmptyTextRange, + textPositionAt, textRangePreview, toMappedOutputs, } from "../src/contentMapperVirtualFiles"; @@ -22,6 +23,13 @@ test("previews virtual ranges with normalized whitespace and safe truncation", ( assert.equal(textRangePreview("A😀BCDE", { pos: 0, end: 7 }, 5), "A😀..."); }); +test("computes positions for LF, CRLF, and lone CR line endings", () => { + const text = "a\rb\r\nc\nd"; + assert.deepEqual(textPositionAt(text, 2), { line: 1, character: 0 }); + assert.deepEqual(textPositionAt(text, 5), { line: 2, character: 0 }); + assert.deepEqual(textPositionAt(text, 7), { line: 3, character: 0 }); +}); + test("adds stable output keys and identities to content mapper virtual files", () => { const files = [{ fileName: "/component.vue.ts", From c62957efe176217c856f3f4718d62b0310a0b22b Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 9 Sep 2026 11:42:23 -0700 Subject: [PATCH 10/11] Simplify and add comments --- .../src/contentMapperVirtualDocuments.ts | 192 ++++++++---------- .../src/contentMapperVirtualFiles.ts | 5 +- .../src/diagnosticDirectivesView.ts | 42 ++-- packages/vscode-typescript/src/session.ts | 1 + .../test/contentMapperVirtualFiles.test.ts | 5 +- .../lsp/lsproto/_generate/generate.mts | 1 + tsc/internal/lsp/lsproto/lsp_generated.go | 2 + tsc/internal/lsp/server.go | 5 +- tsc/internal/lsp/server_contentmapper_test.go | 1 + 9 files changed, 117 insertions(+), 137 deletions(-) diff --git a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts index fbe501943d0dc..6521e111ff6a7 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualDocuments.ts @@ -211,10 +211,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, clearTimeout(this.inspectionTimer); this.inspectionTimer = undefined; } - if (this.activeEditorContextTimer) { - clearTimeout(this.activeEditorContextTimer); - this.activeEditorContextTimer = undefined; - } + this.clearActiveEditorContextTimer(); for (const disposable of this.disposables.splice(0)) { disposable.dispose(); } @@ -230,7 +227,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, return undefined; } const outputs = await this.loadOutputs(parsed.sourceUri); - this.remember(parsed.sourceUri, outputs); + this.storeOutputs(parsed.sourceUri, outputs); this.diagnosticDirectivesView.show(parsed.sourceUri, outputs); return this.entries.get(uri.toString()); } @@ -250,30 +247,8 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, void vscode.window.showInformationMessage(vscode.l10n.t("The active file is not transformed by a TypeScript content mapper.")); return; } - const previousUris = this.sourceToVirtualUris.get(sourceUri.toString()) ?? []; - const previousEntries = new Map(previousUris.map( - uri => [uri.toString(), this.entries.get(uri.toString())] as const, - )); - const virtualUris = this.remember(sourceUri, outputs); + const { virtualUris, changes } = this.storeOutputs(sourceUri, outputs); this.diagnosticDirectivesView.show(sourceUri, outputs); - const nextKeys = new Set(virtualUris.map(uri => uri.toString())); - const changes: vscode.FileChangeEvent[] = []; - for (const uri of previousUris) { - if (!nextKeys.has(uri.toString())) { - changes.push({ type: vscode.FileChangeType.Deleted, uri }); - } - } - for (const uri of virtualUris) { - const previous = previousEntries.get(uri.toString()); - const entry = this.entries.get(uri.toString())!; - if (!previous) { - changes.push({ type: vscode.FileChangeType.Created, uri }); - } - else if (previous.output.identity !== entry.output.identity) { - entry.mtime = Math.max(Date.now(), previous.mtime + 1); - changes.push({ type: vscode.FileChangeType.Changed, uri }); - } - } if (changes.length !== 0) { this.changeEmitter.fire(changes); } @@ -313,10 +288,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } private updateActiveEditorContext(editor: vscode.TextEditor | undefined): void { - if (this.activeEditorContextTimer) { - clearTimeout(this.activeEditorContextTimer); - this.activeEditorContextTimer = undefined; - } + this.clearActiveEditorContextTimer(); const version = ++this.activeEditorContextVersion; void this.updateActiveEditorContextNow(editor, version).catch(error => { this.output.error(`Could not update the active content mapper context: ${String(error)}`); @@ -324,9 +296,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } private scheduleActiveEditorContextUpdate(document: vscode.TextDocument): void { - if (this.activeEditorContextTimer) { - clearTimeout(this.activeEditorContextTimer); - } + this.clearActiveEditorContextTimer(); this.activeEditorContextTimer = setTimeout(() => { this.activeEditorContextTimer = undefined; const editor = vscode.window.activeTextEditor; @@ -336,6 +306,13 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, }, 100); } + private clearActiveEditorContextTimer(): void { + if (this.activeEditorContextTimer) { + clearTimeout(this.activeEditorContextTimer); + this.activeEditorContextTimer = undefined; + } + } + private async updateActiveEditorContextNow(editor: vscode.TextEditor | undefined, version: number): Promise { let isContentMapped = false; if (readNativePreviewConfig("showDebugInfo", false) && editor?.document.uri.scheme === "file") { @@ -351,39 +328,49 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } } - private remember(sourceUri: vscode.Uri, outputs: readonly MappedOutput[]): readonly vscode.Uri[] { + private storeOutputs( + sourceUri: vscode.Uri, + outputs: readonly MappedOutput[], + ): { readonly virtualUris: readonly vscode.Uri[]; readonly changes: vscode.FileChangeEvent[]; } { const sourceKey = sourceUri.toString(); const previousUris = this.sourceToVirtualUris.get(sourceKey) ?? []; const nextUris = outputs.map(output => virtualUriForOutput(sourceUri, output)); const nextKeys = new Set(nextUris.map(uri => uri.toString())); + const changes: vscode.FileChangeEvent[] = []; for (const previousUri of previousUris) { if (!nextKeys.has(previousUri.toString())) { this.entries.delete(previousUri.toString()); + this.highlightedMappings.delete(previousUri.toString()); + changes.push({ type: vscode.FileChangeType.Deleted, uri: previousUri }); } } outputs.forEach((mappedOutput, index) => { const uri = nextUris[index]!; const existing = this.entries.get(uri.toString()); + let mtime = existing?.mtime ?? Date.now(); + if (existing && existing.output.hash !== mappedOutput.hash) { + mtime = Math.max(Date.now(), existing.mtime + 1); + } this.entries.set(uri.toString(), { sourceUri, output: mappedOutput, - mtime: existing?.mtime ?? Date.now(), + mtime, }); + if (!existing) { + changes.push({ type: vscode.FileChangeType.Created, uri }); + } + else if (existing.output.hash !== mappedOutput.hash) { + changes.push({ type: vscode.FileChangeType.Changed, uri }); + } }); this.sourceToVirtualUris.set(sourceKey, nextUris); - return nextUris; + return { virtualUris: nextUris, changes }; } private showDiagnosticDirectivesForEditor(editor: vscode.TextEditor | undefined): vscode.Uri | undefined { - if (!editor) { - this.diagnosticDirectivesView.clear(); - return undefined; - } - const sourceUri = editor.document.uri.scheme === virtualDocumentScheme - ? this.entries.get(editor.document.uri.toString())?.sourceUri - : this.sourceToVirtualUris.has(editor.document.uri.toString()) ? editor.document.uri : undefined; + const sourceUri = editor && this.inspectedSourceUri(editor); if (!sourceUri) { this.diagnosticDirectivesView.clear(); return undefined; @@ -395,6 +382,13 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, return sourceUri; } + private inspectedSourceUri(editor: vscode.TextEditor): vscode.Uri | undefined { + if (editor.document.uri.scheme === virtualDocumentScheme) { + return this.entries.get(editor.document.uri.toString())?.sourceUri; + } + return this.sourceToVirtualUris.has(editor.document.uri.toString()) ? editor.document.uri : undefined; + } + private scheduleSourceEviction(closedVirtualUri: vscode.Uri): void { const entry = this.entries.get(closedVirtualUri.toString()); const parsed = entry ? undefined : parseVirtualUri(closedVirtualUri); @@ -407,6 +401,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, if (existing) { clearTimeout(existing); } + // setTextDocumentLanguage emits a close/open pair, so wait a turn before deciding the URI is unused. this.evictionTimers.set( sourceKey, setTimeout(() => { @@ -432,14 +427,21 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, clearTimeout(refreshTimer); this.refreshTimers.delete(sourceKey); } + this.deleteCachedSource(sourceUri); + this.diagnosticDirectivesView.clear(sourceUri); + this.scheduleInspection(); + } + + private deleteCachedSource(sourceUri: vscode.Uri): readonly vscode.Uri[] { + const sourceKey = sourceUri.toString(); + const virtualUris = this.sourceToVirtualUris.get(sourceKey) ?? []; this.sourceToVirtualUris.delete(sourceKey); for (const uri of virtualUris) { const key = uri.toString(); this.entries.delete(key); this.highlightedMappings.delete(key); } - this.diagnosticDirectivesView.clear(sourceUri); - this.scheduleInspection(); + return virtualUris; } private scheduleRefresh(sourceUri: vscode.Uri): void { @@ -474,43 +476,21 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } const outputs = await this.loadOutputs(sourceUri); + // Ignore a response if the source was evicted or replaced while the request was in flight. if (this.sourceToVirtualUris.get(sourceKey) !== previousUris) { return; } if (outputs.length === 0) { this.diagnosticDirectivesView.refresh(sourceUri, undefined); - this.sourceToVirtualUris.delete(sourceKey); - const changes = previousUris.map(uri => ({ type: vscode.FileChangeType.Deleted, uri })); - for (const uri of previousUris) { - this.entries.delete(uri.toString()); - } + const changes = this.deleteCachedSource(sourceUri) + .map(uri => ({ type: vscode.FileChangeType.Deleted, uri })); this.changeEmitter.fire(changes); this.scheduleInspection(); return; } - const previousEntries = new Map(previousUris.map(uri => [uri.toString(), this.entries.get(uri.toString())])); - const nextUris = this.remember(sourceUri, outputs); + const { changes } = this.storeOutputs(sourceUri, outputs); this.diagnosticDirectivesView.refresh(sourceUri, outputs); - const nextKeys = new Set(nextUris.map(uri => uri.toString())); - const changes: vscode.FileChangeEvent[] = []; - - for (const uri of previousUris) { - if (!nextKeys.has(uri.toString())) { - changes.push({ type: vscode.FileChangeType.Deleted, uri }); - } - } - for (const uri of nextUris) { - const entry = this.entries.get(uri.toString())!; - const previous = previousEntries.get(uri.toString()); - if (!previous) { - changes.push({ type: vscode.FileChangeType.Created, uri }); - } - else if (previous.output.identity !== entry.output.identity) { - entry.mtime = Math.max(Date.now(), previous.mtime + 1); - changes.push({ type: vscode.FileChangeType.Changed, uri }); - } - } if (changes.length !== 0) { this.changeEmitter.fire(changes); } @@ -518,10 +498,7 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } private async revealDiagnosticDirective(node: DiagnosticDirectiveNode): Promise { - const sourceUri = this.sourceUriForOutput(node.output); - if (!sourceUri) { - return; - } + const sourceUri = node.sourceUri; const virtualUri = virtualUriForOutput(sourceUri, node.output); const entry = await this.getOrCreateEntry(virtualUri); if (!entry) { @@ -573,15 +550,6 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, } } - private sourceUriForOutput(output: MappedOutput): vscode.Uri | undefined { - for (const entry of this.entries.values()) { - if (entry.output === output) { - return entry.sourceUri; - } - } - return undefined; - } - private scheduleInspection(): void { if (this.inspectionTimer) { clearTimeout(this.inspectionTimer); @@ -661,18 +629,10 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, if (mappings.length === 0) { continue; } - const virtualRanges: vscode.Range[][] = [[], [], []]; - for (const mapping of mappings) { - const kind = normalizedMappingKind(mapping.kind); - if (mapping.originalLength !== 0) { - sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); - } - if (mapping.generatedLength !== 0) { - virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); - } - } + const ranges = mappingDecorationRanges(sourceEditor.document, virtualEditor.document, mappings); for (let kind = 0; kind < this.mappingDecorations.length; kind++) { - virtualEditor.setDecorations(this.mappingDecorations[kind]!, virtualRanges[kind]!); + sourceRanges[kind]!.push(...ranges.source[kind]!); + virtualEditor.setDecorations(this.mappingDecorations[kind]!, ranges.virtual[kind]!); } this.decoratedEditors.add(virtualEditor); this.highlightedMappings.set(virtualEditor.document.uri.toString(), mappings); @@ -694,20 +654,10 @@ class ContentMapperVirtualDocumentProvider implements vscode.FileSystemProvider, return; } - const sourceRanges: vscode.Range[][] = [[], [], []]; - const virtualRanges: vscode.Range[][] = [[], [], []]; - for (const mapping of mappings) { - const kind = normalizedMappingKind(mapping.kind); - if (mapping.originalLength !== 0) { - sourceRanges[kind]!.push(rangeFromOffsets(sourceEditor.document, mapping.originalStart, mapping.originalLength)); - } - if (mapping.generatedLength !== 0) { - virtualRanges[kind]!.push(rangeFromOffsets(virtualEditor.document, mapping.generatedStart, mapping.generatedLength)); - } - } + const ranges = mappingDecorationRanges(sourceEditor.document, virtualEditor.document, mappings); for (let kind = 0; kind < this.mappingDecorations.length; kind++) { - sourceEditor.setDecorations(this.mappingDecorations[kind]!, sourceRanges[kind]!); - virtualEditor.setDecorations(this.mappingDecorations[kind]!, virtualRanges[kind]!); + sourceEditor.setDecorations(this.mappingDecorations[kind]!, ranges.source[kind]!); + virtualEditor.setDecorations(this.mappingDecorations[kind]!, ranges.virtual[kind]!); } this.decoratedEditors.add(sourceEditor); this.decoratedEditors.add(virtualEditor); @@ -761,6 +711,27 @@ function rangeFromTextRange(document: vscode.TextDocument, range: ContentMapperT return new vscode.Range(document.positionAt(range.pos), document.positionAt(range.end)); } +function mappingDecorationRanges( + source: vscode.TextDocument, + virtual: vscode.TextDocument, + mappings: readonly ContentMapperVirtualSpan[], +): { readonly source: vscode.Range[][]; readonly virtual: vscode.Range[][]; } { + const ranges = { + source: [[], [], []] as vscode.Range[][], + virtual: [[], [], []] as vscode.Range[][], + }; + for (const mapping of mappings) { + const kind = normalizedMappingKind(mapping.kind); + if (mapping.originalLength !== 0) { + ranges.source[kind]!.push(rangeFromOffsets(source, mapping.originalStart, mapping.originalLength)); + } + if (mapping.generatedLength !== 0) { + ranges.virtual[kind]!.push(rangeFromOffsets(virtual, mapping.generatedStart, mapping.generatedLength)); + } + } + return ranges; +} + function normalizedMappingKind(kind: number): number { return kind >= 0 && kind <= 2 ? kind : 1; } @@ -778,6 +749,7 @@ function mappingKindName(kind: number): string { } } +// Keep this in the same bit order as spanmap.Feature in the server. const featureLabels = [ () => vscode.l10n.t("Hover"), () => vscode.l10n.t("Signature Help"), diff --git a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts index f9e7322b92511..ea92bc5a35df5 100644 --- a/packages/vscode-typescript/src/contentMapperVirtualFiles.ts +++ b/packages/vscode-typescript/src/contentMapperVirtualFiles.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; - export interface ContentMapperTextRange { readonly pos: number; readonly end: number; @@ -23,6 +21,7 @@ export interface ContentMapperVirtualSpan { export interface ContentMapperVirtualFile { readonly fileName: string; + readonly hash: string; readonly text: string; readonly originalText: string; readonly scriptKind: number; @@ -32,7 +31,6 @@ export interface ContentMapperVirtualFile { export interface MappedOutput extends ContentMapperVirtualFile { readonly key: string; - readonly identity: string; } export function containsNonEmptyTextRange(range: ContentMapperTextRange, offset: number): boolean { @@ -73,6 +71,5 @@ export function toMappedOutputs(files: readonly ContentMapperVirtualFile[]): rea return files.map((file, index) => ({ ...file, key: String(index), - identity: createHash("sha256").update(JSON.stringify(file)).digest("hex"), })); } diff --git a/packages/vscode-typescript/src/diagnosticDirectivesView.ts b/packages/vscode-typescript/src/diagnosticDirectivesView.ts index 824f8f973d3df..131752f661c86 100644 --- a/packages/vscode-typescript/src/diagnosticDirectivesView.ts +++ b/packages/vscode-typescript/src/diagnosticDirectivesView.ts @@ -18,10 +18,12 @@ const diagnosticDirectivesViewId = "typescript.native-preview.contentMapperDiagn interface OutputNode { readonly kind: "output"; readonly output: MappedOutput; + readonly directives: readonly DiagnosticDirectiveNode[]; } export interface DiagnosticDirectiveNode { readonly kind: "directive"; + readonly sourceUri: vscode.Uri; readonly output: MappedOutput; readonly directive: ContentMapperDiagnosticDirective; } @@ -33,9 +35,7 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider; private readonly disposables: vscode.Disposable[] = []; private sourceUri: vscode.Uri | undefined; - private outputs: readonly MappedOutput[] = []; private outputNodes: readonly OutputNode[] = []; - private directiveNodes = new Map(); private selectedDirective: DiagnosticDirectiveNode | undefined; readonly onDidChangeTreeData = this.changeEmitter.event; @@ -57,7 +57,7 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider this.directiveNodes.get(node.output) ?? []), + this.outputNodes.flatMap(node => node.directives), node => containsNonEmptyTextRange(node.directive.originalRange, offset), ); } @@ -147,7 +148,7 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider node.output === output)?.directives ?? [], node => containsNonEmptyTextRange(node.directive.virtualRange, offset), ); } @@ -159,8 +160,8 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider count + output.diagnosticDirectives.length, + const directiveCount = this.outputNodes.reduce( + (count, output) => count + output.directives.length, 0, ); this.treeView.description = directiveCount === 0 ? undefined : String(directiveCount); @@ -169,19 +170,20 @@ export class DiagnosticDirectivesView implements vscode.TreeDataProvider output.diagnosticDirectives.length !== 0) - .map(output => ({ kind: "output", output })); - this.directiveNodes = new Map(this.outputNodes.map(({ output }) => [ - output, - output.diagnosticDirectives.map(directive => ({ - kind: "directive", + .map(output => ({ + kind: "output", output, - directive, - })), - ])); + directives: output.diagnosticDirectives.map(directive => ({ + kind: "directive", + sourceUri, + output, + directive, + })), + })); this.selectedDirective = undefined; } diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 4beb35f80bb71..17182d0859188 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -122,6 +122,7 @@ export class SessionManager implements vscode.Disposable { } async getContentMapperVirtualFiles(uri: vscode.Uri): Promise { + // Restored virtual documents can be requested while extension activation is still starting the server. await this.lifecycleOperation; if (!this.currentSession?.client.isInitialized) { throw new Error(vscode.l10n.t("Language server is not running.")); diff --git a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts index b3d02e885ff3d..8caa5ea13d8e0 100644 --- a/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts +++ b/packages/vscode-typescript/test/contentMapperVirtualFiles.test.ts @@ -30,9 +30,10 @@ test("computes positions for LF, CRLF, and lone CR line endings", () => { assert.deepEqual(textPositionAt(text, 7), { line: 3, character: 0 }); }); -test("adds stable output keys and identities to content mapper virtual files", () => { +test("adds stable output keys to content mapper virtual files", () => { const files = [{ fileName: "/component.vue.ts", + hash: "1234", text: "export {}", originalText: "