diff --git a/extensions/markdown-language-features/media/markdown.css b/extensions/markdown-language-features/media/markdown.css index 800be985a43af0..23ac1867ea8fe0 100644 --- a/extensions/markdown-language-features/media/markdown.css +++ b/extensions/markdown-language-features/media/markdown.css @@ -57,6 +57,29 @@ body.showEditorSelection .code-line { position: relative; } +.code-line-diff { + border-radius: 2px; + box-shadow: -4px 0 0 transparent; +} + +.code-line-diff-added { + background-color: var(--vscode-diffEditor-insertedTextBackground); + box-shadow: -4px 0 0 var(--vscode-diffEditorGutter-insertedLineBackground); +} + +.code-line-diff-deleted { + background-color: var(--vscode-diffEditor-removedTextBackground); + box-shadow: -4px 0 0 var(--vscode-diffEditorGutter-removedLineBackground); +} + +.vscode-high-contrast .code-line-diff-added { + outline: 1px solid var(--vscode-diffEditor-insertedTextBorder); +} + +.vscode-high-contrast .code-line-diff-deleted { + outline: 1px solid var(--vscode-diffEditor-removedTextBorder); +} + body.showEditorSelection :not(tr,ul,ol).code-active-line:before, body.showEditorSelection :not(tr,ul,ol).code-line:hover:before { content: ""; diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index a32ad324731fc2..86161042221dc3 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -7,6 +7,9 @@ "publisher": "vscode", "license": "MIT", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", + "enabledApiProposals": [ + "customEditorDiffs" + ], "engines": { "vscode": "^1.70.0" }, @@ -156,7 +159,7 @@ "command": "markdown.showSource", "title": "%markdown.showSource.title%", "category": "Markdown", - "icon": "$(go-to-file)" + "icon": "$(file-text)" }, { "command": "markdown.showPreviewSecuritySelector", @@ -188,7 +191,7 @@ "command": "markdown.reopenAsSource", "title": "%markdown.reopenAsSource.title%", "category": "Markdown", - "icon": "$(go-to-file)" + "icon": "$(file-text)" }, { "command": "markdown.togglePreview", diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index 087736a6c0e381..463632676312a4 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -6,11 +6,11 @@ import { ActiveLineMarker } from './activeLineMarker'; import { onceDocumentLoaded } from './events'; import { createPosterForVsCode } from './messaging'; -import { getEditorLineNumberForPageOffset, scrollToRevealSourceLine, getLineElementForFragment } from './scroll-sync'; +import { getEditorLineNumberForPageOffset, getElementsForSourceLine, getLineElementForFragment, scrollToRevealSourceLine } from './scroll-sync'; import { SettingsManager, getData, getRawData } from './settings'; import throttle = require('lodash.throttle'); import morphdom from 'morphdom'; -import type { ToWebviewMessage } from '../types/previewMessaging'; +import type { MarkdownPreviewLineChanges, ToWebviewMessage } from '../types/previewMessaging'; import { isOfScheme, Schemes } from '../src/util/schemes'; let scrollDisabledCount = 0; @@ -21,6 +21,7 @@ const settings = new SettingsManager(); let documentVersion = 0; let documentResource = settings.settings.source; +let lineChanges = settings.settings.lineChanges; const vscode = acquireVsCodeApi(); @@ -88,6 +89,7 @@ onceDocumentLoaded(() => { // Restore const scrollProgress = state.scrollProgress; addImageContexts(); + applyLineChanges(lineChanges); if (typeof scrollProgress === 'number' && !settings.settings.fragment) { doAfterImagesLoaded(() => { scrollDisabledCount = 1; @@ -243,6 +245,7 @@ window.addEventListener('message', async event => { return; case 'updateContent': { + lineChanges = data.lineChanges; const root = document.querySelector('.markdown-body')!; const parser = new DOMParser(); @@ -314,11 +317,36 @@ window.addEventListener('message', async event => { window.dispatchEvent(new CustomEvent('vscode.markdown.updateContent')); addImageContexts(); + applyLineChanges(lineChanges); break; } } }, false); +function applyLineChanges(lineChanges: MarkdownPreviewLineChanges | undefined): void { + for (const element of document.querySelectorAll('.code-line-diff-added, .code-line-diff-deleted')) { + element.classList.remove('code-line-diff', 'code-line-diff-added', 'code-line-diff-deleted'); + } + + markChangedLines(lineChanges?.added, 'code-line-diff-added'); + markChangedLines(lineChanges?.deleted, 'code-line-diff-deleted'); +} + +function markChangedLines(lines: readonly number[] | undefined, className: string): void { + if (!lines) { + return; + } + + for (const line of lines) { + const { previous, next } = getElementsForSourceLine(line, documentVersion); + const lineElement = previous.line >= 0 ? previous : next; + const element = lineElement?.codeElement || lineElement?.element; + if (element) { + element.classList.add('code-line-diff', className); + } + } +} + document.addEventListener('dblclick', event => { diff --git a/extensions/markdown-language-features/preview-src/settings.ts b/extensions/markdown-language-features/preview-src/settings.ts index 6d642b58c64e3a..a385a964deb9b5 100644 --- a/extensions/markdown-language-features/preview-src/settings.ts +++ b/extensions/markdown-language-features/preview-src/settings.ts @@ -3,11 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { MarkdownPreviewLineChanges } from '../types/previewMessaging'; + export interface PreviewSettings { readonly source: string; readonly line?: number; readonly fragment?: string; readonly selectedLine?: number; + readonly lineChanges?: MarkdownPreviewLineChanges; readonly scrollPreviewWithEditor?: boolean; readonly scrollEditorWithPreview: boolean; diff --git a/extensions/markdown-language-features/src/preview/documentRenderer.ts b/extensions/markdown-language-features/src/preview/documentRenderer.ts index f96fce9b745a65..d7b50f9bfad9ff 100644 --- a/extensions/markdown-language-features/src/preview/documentRenderer.ts +++ b/extensions/markdown-language-features/src/preview/documentRenderer.ts @@ -13,6 +13,7 @@ import { WebviewResourceProvider } from '../util/resources'; import { generateUuid } from '../util/uuid'; import { MarkdownPreviewConfiguration, MarkdownPreviewConfigurationManager } from './previewConfig'; import { ContentSecurityPolicyArbiter, MarkdownPreviewSecurityLevel } from './security'; +import type { MarkdownPreviewLineChanges } from '../../types/previewMessaging'; /** @@ -76,6 +77,7 @@ export class MdDocumentRenderer { selectedLine: number | undefined, state: any | undefined, imageInfo: readonly ImageInfo[], + lineChanges: MarkdownPreviewLineChanges | undefined, token: vscode.CancellationToken ): Promise { const sourceUri = markdownDocument.uri; @@ -85,6 +87,7 @@ export class MdDocumentRenderer { fragment: state?.fragment || markdownDocument.uri.fragment || undefined, line: initialLine, selectedLine, + lineChanges, scrollPreviewWithEditor: config.scrollPreviewWithEditor, scrollEditorWithPreview: config.scrollEditorWithPreview, doubleClickToSwitchToEditor: config.doubleClickToSwitchToEditor, diff --git a/extensions/markdown-language-features/src/preview/lineDiff.ts b/extensions/markdown-language-features/src/preview/lineDiff.ts new file mode 100644 index 00000000000000..3a9eda944bd698 --- /dev/null +++ b/extensions/markdown-language-features/src/preview/lineDiff.ts @@ -0,0 +1,468 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import type { API as GitAPI, GitExtension, Repository as GitRepository } from '../../../git/src/api/git'; +import type { MarkdownPreviewLineChanges } from '../../types/previewMessaging'; + +interface LineChanges { + readonly added: readonly number[]; + readonly deleted: readonly number[]; + readonly originalToModified: readonly number[]; + readonly modifiedToOriginal: readonly number[]; +} + +interface LineMappings { + readonly originalToModified: number[]; + readonly modifiedToOriginal: number[]; +} + +interface GitUriParams { + readonly path: string; + readonly ref: string; + readonly submoduleOf?: string; +} + +interface GitPatch { + readonly patch: string; + readonly isFullRepositoryDiff: boolean; +} + +export class MarkdownPreviewLineDiffProvider { + + readonly #originalDocument: vscode.TextDocument; + readonly #modifiedDocument: vscode.TextDocument; + + #cachedOriginalVersion = -1; + #cachedModifiedVersion = -1; + #cachedLineChanges: Promise | undefined; + + public constructor( + originalDocument: vscode.TextDocument, + modifiedDocument: vscode.TextDocument, + ) { + this.#originalDocument = originalDocument; + this.#modifiedDocument = modifiedDocument; + } + + public async getOriginalLineChanges(): Promise { + const deleted = (await this.#getLineChanges()).deleted; + return deleted.length ? { deleted } : undefined; + } + + public async getModifiedLineChanges(): Promise { + const added = (await this.#getLineChanges()).added; + return added.length ? { added } : undefined; + } + + public async translateOriginalLineToModified(line: number): Promise { + return translateLine(line, (await this.#getLineChanges()).originalToModified, this.#modifiedDocument.lineCount); + } + + public async translateModifiedLineToOriginal(line: number): Promise { + return translateLine(line, (await this.#getLineChanges()).modifiedToOriginal, this.#originalDocument.lineCount); + } + + #getLineChanges(): Promise { + if (!this.#cachedLineChanges || this.#cachedOriginalVersion !== this.#originalDocument.version || this.#cachedModifiedVersion !== this.#modifiedDocument.version) { + this.#cachedOriginalVersion = this.#originalDocument.version; + this.#cachedModifiedVersion = this.#modifiedDocument.version; + this.#cachedLineChanges = computeLineChanges(this.#originalDocument, this.#modifiedDocument); + } + + return this.#cachedLineChanges; + } +} + +async function computeLineChanges(originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument): Promise { + return await computeGitLineChanges(originalDocument, modifiedDocument) + ?? computeContentLineChanges(getDocumentLines(originalDocument), getDocumentLines(modifiedDocument)); +} + +async function computeGitLineChanges(originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument): Promise { + const gitApi = await getGitApi(); + if (!gitApi) { + return undefined; + } + + const originalUri = originalDocument.uri; + const modifiedUri = modifiedDocument.uri; + const originalGitUri = fromGitUri(originalUri); + const modifiedGitUri = fromGitUri(modifiedUri); + const filePath = originalGitUri?.path ?? modifiedGitUri?.path ?? (modifiedUri.scheme === 'file' ? modifiedUri.fsPath : undefined); + if (!filePath || originalGitUri?.submoduleOf || modifiedGitUri?.submoduleOf) { + return undefined; + } + + const repository = gitApi.getRepository(vscode.Uri.file(filePath)); + if (!repository) { + return undefined; + } + + const diff = await getGitPatch(repository, filePath, originalUri, originalGitUri, modifiedUri, modifiedGitUri); + if (!diff) { + return undefined; + } + + const relativePath = diff.isFullRepositoryDiff ? getRepositoryRelativePath(repository.rootUri, filePath) : undefined; + return diff.isFullRepositoryDiff && relativePath === undefined ? undefined : parseGitPatchLineChanges(diff.patch, relativePath, originalDocument.lineCount, modifiedDocument.lineCount); +} + +async function getGitApi(): Promise { + const gitExtension = vscode.extensions.getExtension('vscode.git'); + if (!gitExtension) { + return undefined; + } + + try { + return (gitExtension.isActive ? gitExtension.exports : await gitExtension.activate()).getAPI(1); + } catch { + return undefined; + } +} + +async function getGitPatch( + repository: GitRepository, + filePath: string, + originalUri: vscode.Uri, + originalGitUri: GitUriParams | undefined, + modifiedUri: vscode.Uri, + modifiedGitUri: GitUriParams | undefined, +): Promise { + try { + if (originalGitUri && !modifiedGitUri && modifiedUri.scheme === 'file' && samePath(originalGitUri.path, modifiedUri.fsPath)) { + if (originalGitUri.ref === '~') { + return { patch: await repository.diff(false), isFullRepositoryDiff: true }; + } + if (originalGitUri.ref === 'HEAD') { + return { patch: await repository.diffWithHEAD(filePath), isFullRepositoryDiff: false }; + } + return { patch: await repository.diffWith(originalGitUri.ref, filePath), isFullRepositoryDiff: false }; + } + + if (originalGitUri && modifiedGitUri && samePath(originalGitUri.path, modifiedGitUri.path)) { + if (modifiedGitUri.ref === '') { + return { + patch: originalGitUri.ref === 'HEAD' + ? await repository.diffIndexWithHEAD(filePath) + : await repository.diffIndexWith(originalGitUri.ref, filePath), + isFullRepositoryDiff: false + }; + } + + return { patch: await repository.diffBetween(originalGitUri.ref, modifiedGitUri.ref, filePath), isFullRepositoryDiff: false }; + } + + if (!originalGitUri && modifiedGitUri && originalUri.scheme === 'file' && samePath(originalUri.fsPath, modifiedGitUri.path)) { + return { + patch: modifiedGitUri.ref === 'HEAD' + ? await repository.diffWithHEAD(filePath) + : await repository.diffWith(modifiedGitUri.ref, filePath), + isFullRepositoryDiff: false + }; + } + } catch { + return undefined; + } + + return undefined; +} + +function fromGitUri(uri: vscode.Uri): GitUriParams | undefined { + if (uri.scheme !== 'git') { + return undefined; + } + + try { + const value = JSON.parse(uri.query) as GitUriParams; + return typeof value.path === 'string' && typeof value.ref === 'string' ? value : undefined; + } catch { + return undefined; + } +} + +function getRepositoryRelativePath(rootUri: vscode.Uri, filePath: string): string | undefined { + const root = normalizePath(rootUri.fsPath).replace(/\/+$/, ''); + const file = normalizePath(filePath); + if (file === root) { + return ''; + } + + return file.toLowerCase().startsWith(`${root.toLowerCase()}/`) ? file.slice(root.length + 1) : undefined; +} + +function samePath(a: string, b: string): boolean { + return normalizePath(a).toLowerCase() === normalizePath(b).toLowerCase(); +} + +function normalizePath(value: string): string { + return value.replace(/\\/g, '/'); +} + +function getDocumentLines(document: vscode.TextDocument): string[] { + const lines: string[] = []; + for (let i = 0; i < document.lineCount; ++i) { + lines.push(document.lineAt(i).text); + } + return lines; +} + +function computeContentLineChanges(originalLines: readonly string[], modifiedLines: readonly string[]): LineChanges { + let start = 0; + while (start < originalLines.length && start < modifiedLines.length && originalLines[start] === modifiedLines[start]) { + ++start; + } + + let originalEnd = originalLines.length; + let modifiedEnd = modifiedLines.length; + while (originalEnd > start && modifiedEnd > start && originalLines[originalEnd - 1] === modifiedLines[modifiedEnd - 1]) { + --originalEnd; + --modifiedEnd; + } + + const originalCount = originalEnd - start; + const modifiedCount = modifiedEnd - start; + if (!originalCount && !modifiedCount) { + return createIdentityLineChanges(originalLines.length, modifiedLines.length); + } + + if (originalCount * modifiedCount > 500_000) { + return computeFallbackLineChanges(originalLines, modifiedLines, start, originalEnd, modifiedEnd); + } + + return computeLcsLineChanges(originalLines, modifiedLines, start, originalEnd, modifiedEnd); +} + +function parseGitPatchLineChanges(patch: string, relativePath: string | undefined, originalLineCount: number, modifiedLineCount: number): LineChanges { + const added: number[] = []; + const deleted: number[] = []; + const mappings = createEmptyLineMappings(originalLineCount, modifiedLineCount); + const lines = patch.split(/\r?\n/); + let originalLine = 0; + let modifiedLine = 0; + let inHunk = false; + let fileMatches = !relativePath; + let matchedFile = !relativePath; + let oldPath: string | undefined; + let deletedBlockStart: number | undefined; + + const finishFile = () => { + if (fileMatches && matchedFile) { + fillUnchangedLineMappings(mappings, originalLine, originalLineCount, modifiedLine, modifiedLineCount); + } + }; + + for (const line of lines) { + if (line.startsWith('diff --git ')) { + finishFile(); + inHunk = false; + fileMatches = !relativePath; + matchedFile = !relativePath; + originalLine = 0; + modifiedLine = 0; + oldPath = undefined; + deletedBlockStart = undefined; + continue; + } + + if (!inHunk && line.startsWith('--- ')) { + oldPath = parseGitDiffPath(line.slice(4)); + continue; + } + + if (!inHunk && line.startsWith('+++ ')) { + const newPath = parseGitDiffPath(line.slice(4)); + fileMatches = !relativePath || oldPath === relativePath || newPath === relativePath; + matchedFile = matchedFile || fileMatches; + continue; + } + + const hunkMatch = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunkMatch) { + inHunk = true; + const nextOriginalLine = Math.max(0, Number(hunkMatch[1]) - 1); + const nextModifiedLine = Math.max(0, Number(hunkMatch[2]) - 1); + if (fileMatches) { + fillUnchangedLineMappings(mappings, originalLine, nextOriginalLine, modifiedLine, nextModifiedLine); + } + originalLine = nextOriginalLine; + modifiedLine = nextModifiedLine; + deletedBlockStart = undefined; + continue; + } + + if (!inHunk || !fileMatches || !line) { + continue; + } + + switch (line[0]) { + case ' ': + deletedBlockStart = undefined; + mappings.originalToModified[originalLine] = clampLine(modifiedLine, modifiedLineCount); + mappings.modifiedToOriginal[modifiedLine] = clampLine(originalLine, originalLineCount); + ++originalLine; + ++modifiedLine; + break; + case '-': + deletedBlockStart ??= originalLine; + mappings.originalToModified[originalLine] = clampLine(modifiedLine, modifiedLineCount); + deleted.push(originalLine++); + break; + case '+': + mappings.modifiedToOriginal[modifiedLine] = clampLine(deletedBlockStart ?? originalLine, originalLineCount); + added.push(modifiedLine++); + break; + case '\\': + break; + } + } + finishFile(); + fillMissingLineMappings(mappings); + + return { added, deleted, ...mappings }; +} + +function parseGitDiffPath(rawPath: string): string | undefined { + if (rawPath === '/dev/null') { + return undefined; + } + + const path = rawPath.startsWith('"') && rawPath.endsWith('"') ? rawPath.slice(1, -1) : rawPath; + return path.startsWith('a/') || path.startsWith('b/') ? path.slice(2) : path; +} + +function computeLcsLineChanges(originalLines: readonly string[], modifiedLines: readonly string[], start: number, originalEnd: number, modifiedEnd: number): LineChanges { + const originalCount = originalEnd - start; + const modifiedCount = modifiedEnd - start; + const mappings = createEmptyLineMappings(originalLines.length, modifiedLines.length); + fillUnchangedLineMappings(mappings, 0, start, 0, start); + fillUnchangedLineMappings(mappings, originalEnd, originalLines.length, modifiedEnd, modifiedLines.length); + const lcsLengths: Uint32Array[] = []; + for (let i = 0; i <= originalCount; ++i) { + lcsLengths.push(new Uint32Array(modifiedCount + 1)); + } + + for (let i = originalCount - 1; i >= 0; --i) { + for (let j = modifiedCount - 1; j >= 0; --j) { + lcsLengths[i][j] = originalLines[start + i] === modifiedLines[start + j] + ? lcsLengths[i + 1][j + 1] + 1 + : Math.max(lcsLengths[i + 1][j], lcsLengths[i][j + 1]); + } + } + + const added: number[] = []; + const deleted: number[] = []; + let originalIndex = 0; + let modifiedIndex = 0; + let deletedBlockStart: number | undefined; + let addedBlockStart: number | undefined; + while (originalIndex < originalCount || modifiedIndex < modifiedCount) { + if (originalIndex < originalCount && modifiedIndex < modifiedCount && originalLines[start + originalIndex] === modifiedLines[start + modifiedIndex]) { + deletedBlockStart = undefined; + addedBlockStart = undefined; + mappings.originalToModified[start + originalIndex] = clampLine(start + modifiedIndex, modifiedLines.length); + mappings.modifiedToOriginal[start + modifiedIndex] = clampLine(start + originalIndex, originalLines.length); + ++originalIndex; + ++modifiedIndex; + } else if (modifiedIndex < modifiedCount && (originalIndex === originalCount || lcsLengths[originalIndex][modifiedIndex + 1] >= lcsLengths[originalIndex + 1][modifiedIndex])) { + added.push(start + modifiedIndex); + addedBlockStart ??= start + modifiedIndex; + mappings.modifiedToOriginal[start + modifiedIndex] = clampLine(deletedBlockStart ?? start + originalIndex, originalLines.length); + ++modifiedIndex; + } else { + deleted.push(start + originalIndex); + deletedBlockStart ??= start + originalIndex; + mappings.originalToModified[start + originalIndex] = clampLine(addedBlockStart ?? start + modifiedIndex, modifiedLines.length); + ++originalIndex; + } + } + fillMissingLineMappings(mappings); + + return { added, deleted, ...mappings }; +} + +function computeFallbackLineChanges(originalLines: readonly string[], modifiedLines: readonly string[], start: number, originalEnd: number, modifiedEnd: number): LineChanges { + const added: number[] = []; + const deleted: number[] = []; + const mappings = createEmptyLineMappings(originalLines.length, modifiedLines.length); + fillUnchangedLineMappings(mappings, 0, start, 0, start); + fillUnchangedLineMappings(mappings, originalEnd, originalLines.length, modifiedEnd, modifiedLines.length); + const sharedCount = Math.min(originalEnd - start, modifiedEnd - start); + for (let i = 0; i < sharedCount; ++i) { + mappings.originalToModified[start + i] = clampLine(start + i, modifiedLines.length); + mappings.modifiedToOriginal[start + i] = clampLine(start + i, originalLines.length); + if (originalLines[start + i] !== modifiedLines[start + i]) { + deleted.push(start + i); + added.push(start + i); + } + } + + for (let i = start + sharedCount; i < originalEnd; ++i) { + deleted.push(i); + mappings.originalToModified[i] = clampLine(modifiedEnd, modifiedLines.length); + } + for (let i = start + sharedCount; i < modifiedEnd; ++i) { + added.push(i); + mappings.modifiedToOriginal[i] = clampLine(originalEnd, originalLines.length); + } + fillMissingLineMappings(mappings); + + return { added, deleted, ...mappings }; +} + +function createIdentityLineChanges(originalLineCount: number, modifiedLineCount: number): LineChanges { + const mappings = createEmptyLineMappings(originalLineCount, modifiedLineCount); + fillUnchangedLineMappings(mappings, 0, originalLineCount, 0, modifiedLineCount); + fillMissingLineMappings(mappings); + return { added: [], deleted: [], ...mappings }; +} + +function createEmptyLineMappings(originalLineCount: number, modifiedLineCount: number): LineMappings { + return { + originalToModified: new Array(originalLineCount), + modifiedToOriginal: new Array(modifiedLineCount), + }; +} + +function fillUnchangedLineMappings(mappings: LineMappings, originalStart: number, originalEnd: number, modifiedStart: number, modifiedEnd: number): void { + const count = Math.min(originalEnd - originalStart, modifiedEnd - modifiedStart); + for (let i = 0; i < count; ++i) { + mappings.originalToModified[originalStart + i] = clampLine(modifiedStart + i, mappings.modifiedToOriginal.length); + mappings.modifiedToOriginal[modifiedStart + i] = clampLine(originalStart + i, mappings.originalToModified.length); + } +} + +function fillMissingLineMappings(mappings: LineMappings): void { + for (let i = 0; i < mappings.originalToModified.length; ++i) { + if (typeof mappings.originalToModified[i] !== 'number') { + mappings.originalToModified[i] = clampLine(i, mappings.modifiedToOriginal.length); + } + } + for (let i = 0; i < mappings.modifiedToOriginal.length; ++i) { + if (typeof mappings.modifiedToOriginal[i] !== 'number') { + mappings.modifiedToOriginal[i] = clampLine(i, mappings.originalToModified.length); + } + } +} + +function translateLine(line: number, mappings: readonly number[], targetLineCount: number): number { + const sourceLine = Math.floor(line); + const progress = line - sourceLine; + const mappedLine = mappings[sourceLine] ?? line; + if (progress <= 0) { + return clampLine(mappedLine, targetLineCount); + } + + const nextMappedLine = mappings[sourceLine + 1]; + if (typeof nextMappedLine !== 'number') { + return clampLine(mappedLine + progress, targetLineCount); + } + + return clampLine(mappedLine + ((nextMappedLine - mappedLine) * progress), targetLineCount); +} + +function clampLine(line: number, lineCount: number): number { + return Math.max(0, Math.min(line, lineCount - 1)); +} diff --git a/extensions/markdown-language-features/src/preview/preview.ts b/extensions/markdown-language-features/src/preview/preview.ts index ba974a1c2fdf6b..5a5649981d73f2 100644 --- a/extensions/markdown-language-features/src/preview/preview.ts +++ b/extensions/markdown-language-features/src/preview/preview.ts @@ -16,7 +16,7 @@ import { ImageInfo, MdDocumentRenderer } from './documentRenderer'; import { MarkdownPreviewConfigurationManager } from './previewConfig'; import { scrollEditorToLine, StartingScrollFragment, StartingScrollLine, StartingScrollLocation } from './scrolling'; import { getVisibleLine, LastScrollLocation, TopmostLineMonitor } from './topmostLineMonitor'; -import type { FromWebviewMessage, ToWebviewMessage } from '../../types/previewMessaging'; +import type { FromWebviewMessage, MarkdownPreviewLineChanges, ToWebviewMessage } from '../../types/previewMessaging'; export class PreviewDocumentVersion { @@ -37,6 +37,7 @@ export class PreviewDocumentVersion { interface MarkdownPreviewDelegate { getTitle?(resource: vscode.Uri): string; getAdditionalState(): {}; + getLineChanges?(): MarkdownPreviewLineChanges | Promise | undefined; openPreviewLinkToMarkdownFile(markdownLink: vscode.Uri, fragment: string | undefined): void; } @@ -292,14 +293,15 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { } } + const lineChanges = await this.#delegate.getLineChanges?.(); const content = await (shouldReloadPage - ? this.#contentProvider.renderDocument(document, this, this.#previewConfigurations, this.#line, selectedLine, this.state, this.#imageInfo, this.#disposeCts.token) + ? this.#contentProvider.renderDocument(document, this, this.#previewConfigurations, this.#line, selectedLine, this.state, this.#imageInfo, lineChanges, this.#disposeCts.token) : this.#contentProvider.renderBody(document, this)); // Another call to `doUpdate` may have happened. // Make sure we are still updating for the correct document if (this.#currentVersion?.equals(pendingVersion)) { - this.#updateWebviewContent(content.html, shouldReloadPage); + this.#updateWebviewContent(content.html, shouldReloadPage, lineChanges); this.#updateImageWatchers(content.containingImages); } } @@ -360,7 +362,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { this.#webviewPanel.webview.html = this.#contentProvider.renderFileNotFoundDocument(this.#resource); } - #updateWebviewContent(html: string, reloadPage: boolean): void { + #updateWebviewContent(html: string, reloadPage: boolean, lineChanges: MarkdownPreviewLineChanges | undefined): void { if (this.#disposed) { return; } @@ -376,6 +378,7 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider { this.postMessage({ type: 'updateContent', content: html, + lineChanges, source: this.#resource.toString(), }); } @@ -505,10 +508,11 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow contributionProvider: MarkdownContributionProvider, opener: MdLinkOpener, scrollLine?: number, + getLineChanges?: () => MarkdownPreviewLineChanges | Promise | undefined, ): StaticMarkdownPreview { webview.iconPath = contentProvider.iconPath; - return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, opener, scrollLine); + return new StaticMarkdownPreview(webview, resource, contentProvider, previewConfigurations, topmostLineMonitor, logger, contributionProvider, opener, scrollLine, getLineChanges); } readonly #preview: MarkdownPreview; @@ -526,6 +530,7 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow contributionProvider: MarkdownContributionProvider, opener: MdLinkOpener, scrollLine?: number, + getLineChanges?: () => MarkdownPreviewLineChanges | Promise | undefined, ) { super(); @@ -535,6 +540,7 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow const topScrollLocation = scrollLine ? new StartingScrollLine(scrollLine) : undefined; this.#preview = this._register(new MarkdownPreview(this.#webviewPanel, resource, topScrollLocation, { getAdditionalState: () => { return {}; }, + getLineChanges, openPreviewLinkToMarkdownFile: (markdownLink, fragment) => { return vscode.commands.executeCommand('vscode.openWith', markdownLink.with({ fragment @@ -595,6 +601,14 @@ export class StaticMarkdownPreview extends Disposable implements IManagedMarkdow this.#preview.refresh(true); } + public scrollTo(line: number): void { + this.#preview.scrollTo(line); + } + + public get onScroll(): vscode.Event { + return this.#preview.onScroll; + } + public updateConfiguration() { if (this.#previewConfigurations.hasConfigurationChanged(this.#preview.resource)) { this.refresh(); diff --git a/extensions/markdown-language-features/src/preview/previewManager.ts b/extensions/markdown-language-features/src/preview/previewManager.ts index cc1f7fda40b63e..cc6018b6d3b223 100644 --- a/extensions/markdown-language-features/src/preview/previewManager.ts +++ b/extensions/markdown-language-features/src/preview/previewManager.ts @@ -10,10 +10,12 @@ import { Disposable, disposeAll } from '../util/dispose'; import { isMarkdownFile } from '../util/file'; import { MdLinkOpener } from '../util/openDocumentLink'; import { MdDocumentRenderer } from './documentRenderer'; +import { MarkdownPreviewLineDiffProvider } from './lineDiff'; import { DynamicMarkdownPreview, IManagedMarkdownPreview, StaticMarkdownPreview } from './preview'; import { MarkdownPreviewConfigurationManager } from './previewConfig'; import { scrollEditorToLine, StartingScrollFragment } from './scrolling'; import { TopmostLineMonitor } from './topmostLineMonitor'; +import type { MarkdownPreviewLineChanges } from '../../types/previewMessaging'; export interface DynamicPreviewSettings { @@ -247,6 +249,35 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview document: vscode.TextDocument, webview: vscode.WebviewPanel ): Promise { + this.#resolveCustomTextEditor(document, webview); + } + + public async resolveCustomTextEditorInlineDiff( + documents: vscode.CustomEditorDiffDocuments, + webview: vscode.WebviewPanel + ): Promise { + const lineDiffProvider = new MarkdownPreviewLineDiffProvider(documents.original, documents.modified); + const preview = this.#resolveCustomTextEditor(documents.modified, webview, () => lineDiffProvider.getModifiedLineChanges()); + this.#refreshPreviewWhenDocumentChanges(preview, documents.original); + } + + public async resolveCustomTextEditorSideBySideDiff( + documents: vscode.CustomEditorDiffDocuments, + webviewPanels: vscode.CustomEditorDiffWebviewPanels + ): Promise { + const lineDiffProvider = new MarkdownPreviewLineDiffProvider(documents.original, documents.modified); + const originalPreview = this.#resolveCustomTextEditor(documents.original, webviewPanels.original, () => lineDiffProvider.getOriginalLineChanges()); + const modifiedPreview = this.#resolveCustomTextEditor(documents.modified, webviewPanels.modified, () => lineDiffProvider.getModifiedLineChanges()); + this.#refreshPreviewWhenDocumentChanges(originalPreview, documents.modified); + this.#refreshPreviewWhenDocumentChanges(modifiedPreview, documents.original); + this.#syncSideBySidePreviewScrolling(originalPreview, modifiedPreview, lineDiffProvider); + } + + #resolveCustomTextEditor( + document: vscode.TextDocument, + webview: vscode.WebviewPanel, + getLineChanges?: () => MarkdownPreviewLineChanges | Promise | undefined, + ): StaticMarkdownPreview { const lineNumber = this.#topmostLineMonitor.getPreviousStaticTextEditorLineByUri(document.uri); const preview = StaticMarkdownPreview.revive( document.uri, @@ -257,10 +288,97 @@ export class MarkdownPreviewManager extends Disposable implements vscode.Webview this.#logger, this.#contributions, this.#opener, - lineNumber + lineNumber, + getLineChanges ); this.#registerStaticPreview(preview); this.#activePreview = preview; + return preview; + } + + #refreshPreviewWhenDocumentChanges(preview: StaticMarkdownPreview, document: vscode.TextDocument): void { + const listener = vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.toString() === document.uri.toString()) { + preview.refresh(); + } + }); + preview.onDispose(() => listener.dispose()); + } + + #syncSideBySidePreviewScrolling(originalPreview: StaticMarkdownPreview, modifiedPreview: StaticMarkdownPreview, lineDiffProvider: MarkdownPreviewLineDiffProvider): void { + let disposed = false; + let ignoreOriginalScrollUntil = 0; + let ignoreModifiedScrollUntil = 0; + let originalToModifiedRequest = 0; + let modifiedToOriginalRequest = 0; + + const syncScroll = async ( + line: number, + targetPreview: StaticMarkdownPreview, + translateLine: (line: number) => Promise, + request: number, + shouldApply: (request: number) => boolean, + ignoreTargetScroll: () => void, + ) => { + if (disposed) { + return; + } + + let targetLine: number; + try { + targetLine = await translateLine(line); + } catch { + targetLine = line; + } + + if (!disposed && shouldApply(request)) { + ignoreTargetScroll(); + targetPreview.scrollTo(targetLine); + } + }; + + const disposables = [ + originalPreview.onScroll(({ line }) => { + if (Date.now() < ignoreOriginalScrollUntil) { + return; + } + const request = ++originalToModifiedRequest; + void syncScroll( + line, + modifiedPreview, + value => lineDiffProvider.translateOriginalLineToModified(value), + request, + value => value === originalToModifiedRequest, + () => { ignoreModifiedScrollUntil = Date.now() + 100; }, + ); + }), + modifiedPreview.onScroll(({ line }) => { + if (Date.now() < ignoreModifiedScrollUntil) { + return; + } + const request = ++modifiedToOriginalRequest; + void syncScroll( + line, + originalPreview, + value => lineDiffProvider.translateModifiedLineToOriginal(value), + request, + value => value === modifiedToOriginalRequest, + () => { ignoreOriginalScrollUntil = Date.now() + 100; }, + ); + }), + ]; + + const dispose = () => { + if (disposed) { + return; + } + disposed = true; + ++originalToModifiedRequest; + ++modifiedToOriginalRequest; + disposeAll(disposables); + }; + originalPreview.onDispose(dispose); + modifiedPreview.onDispose(dispose); } #createNewDynamicPreview( diff --git a/extensions/markdown-language-features/tsconfig.json b/extensions/markdown-language-features/tsconfig.json index 6ae3def2ed1fd8..b1b21973b6073c 100644 --- a/extensions/markdown-language-features/tsconfig.json +++ b/extensions/markdown-language-features/tsconfig.json @@ -10,6 +10,7 @@ }, "include": [ "src/**/*", - "../../src/vscode-dts/vscode.d.ts" + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.customEditorDiffs.d.ts" ] } diff --git a/extensions/markdown-language-features/types/previewMessaging.d.ts b/extensions/markdown-language-features/types/previewMessaging.d.ts index 686b21bf1a91f4..95221e2d0f28d7 100644 --- a/extensions/markdown-language-features/types/previewMessaging.d.ts +++ b/extensions/markdown-language-features/types/previewMessaging.d.ts @@ -7,6 +7,11 @@ interface BaseMessage { readonly source: string; } +export interface MarkdownPreviewLineChanges { + readonly added?: readonly number[]; + readonly deleted?: readonly number[]; +} + export namespace FromWebviewMessage { export interface CacheImageSizes extends BaseMessage { @@ -63,6 +68,7 @@ export namespace ToWebviewMessage { export interface UpdateContent extends BaseMessage { readonly type: 'updateContent'; readonly content: string; + readonly lineChanges?: MarkdownPreviewLineChanges; } export interface CopyImageContent extends BaseMessage { diff --git a/src/vs/base/test/common/timeTravelScheduler.ts b/src/vs/base/test/common/timeTravelScheduler.ts index c0ccae014be808..445247624d736b 100644 --- a/src/vs/base/test/common/timeTravelScheduler.ts +++ b/src/vs/base/test/common/timeTravelScheduler.ts @@ -3,910 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { compareBy, numberComparator, tieBreakComparators } from '../../common/arrays.js'; -import { CancellationToken, CancellationTokenSource } from '../../common/cancellation.js'; -import { Emitter } from '../../common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../common/lifecycle.js'; -import { setTimeout0, setTimeout0IsFaster } from '../../common/platform.js'; -import { ROOT_TRACE, Trace, TraceContext } from './traceableTimeApi.js'; - -export type TimeOffset = number; - -export interface Scheduler { - schedule(task: ScheduledTask): IDisposable; - get now(): TimeOffset; -} - -export interface ScheduledTask { - readonly time: TimeOffset; - readonly source: ScheduledTaskSource; - readonly useRealAnimationFrame?: boolean; - /** - * Causal trace attached at schedule time. Used for attribution in - * `toString()` and to re-install the trace when the task runs so that - * the task body (and its microtask drain) observes it. - */ - readonly trace?: Trace; - - run(): void; -} - -export interface ScheduledTaskSource { - toString(): string; - readonly stackTrace: string | undefined; -} - -export interface TimeApi { - setTimeout(handler: TimerHandler, timeout?: number): any; - clearTimeout(id: any): void; - setInterval(handler: TimerHandler, interval: number): any; - clearInterval(id: any): void; - setImmediate?: ((handler: () => void) => any); - clearImmediate?: ((id: any) => void); - requestAnimationFrame?: ((callback: (time: number) => void) => number); - cancelAnimationFrame?: ((id: number) => void); - Date: DateConstructor; - originalFunctions?: TimeApi; -} - -interface ExtendedScheduledTask extends ScheduledTask { - id: number; -} - -const scheduledTaskComparator = tieBreakComparators( - compareBy(i => i.time, numberComparator), - compareBy(i => i.id, numberComparator), -); - -export class TimeTravelScheduler implements Scheduler { - private taskCounter = 0; - private _nowMs: TimeOffset = 0; - private readonly queue: PriorityQueue = new SimplePriorityQueue([], scheduledTaskComparator); - - private readonly taskScheduledEmitter = new Emitter<{ task: ScheduledTask }>(); - public readonly onTaskScheduled = this.taskScheduledEmitter.event; - - constructor(startTimeMs: number) { - this._nowMs = startTimeMs; - } - - schedule(task: ScheduledTask): IDisposable { - if (task.time < this._nowMs) { - throw new Error(`Scheduled time (${task.time}) must be equal to or greater than the current time (${this._nowMs}).`); - } - const extendedTask: ExtendedScheduledTask = { ...task, id: this.taskCounter++ }; - this.queue.add(extendedTask); - this.taskScheduledEmitter.fire({ task }); - return { dispose: () => this.queue.remove(extendedTask) }; - } - - get now(): TimeOffset { - return this._nowMs; - } - - get hasScheduledTasks(): boolean { - return this.queue.length > 0; - } - - peekNext(): ScheduledTask | undefined { - return this.queue.getMin(); - } - - getScheduledTasks(): readonly ScheduledTask[] { - return this.queue.toSortedArray(); - } - - runNext(): ScheduledTask | undefined { - const task = this.queue.removeMin(); - if (task) { - this._nowMs = task.time; - task.run(); - } - - return task; - } - - installGlobally(options?: CreateVirtualTimeApiOptions): IDisposable { - return pushGlobalTimeApi(createVirtualTimeApi(this, options)); - } -} - -/** - * Termination policy of a single {@link AsyncSchedulerProcessor.run} call. - */ -export interface RunOptions { - /** - * If set, the run resolves once the token is cancelled AND the virtual queue - * has been drained. Tasks scheduled before cancellation are still processed. - * If unset, the run resolves as soon as the virtual queue is empty. - */ - readonly token?: CancellationToken; - /** - * If set, the run resolves once virtual time has reached this absolute - * timestamp, OR there is no scheduled task with `time <= virtualDeadline` - * (because then virtual time can never reach the deadline by itself). - */ - readonly virtualDeadline?: TimeOffset; - /** - * Maximum number of virtual tasks this run will tolerate executing while - * its termination predicate is not yet satisfied. Exceeding this rejects - * the run with a debug-friendly overflow error. - * - * Counted from the moment the run was started, not from processor creation. - */ - readonly maxTasks?: number; - /** - * Maximum causal chain depth (via {@link Trace.depth}) the run will - * tolerate. If a task is about to execute whose trace depth exceeds this - * limit, the run is rejected. Useful for catching runaway self-rescheduling - * (a timer that keeps scheduling its own successor indefinitely). - */ - readonly maxTaskDepth?: number; -} - -type RunStatus = 'continue' | 'done' | { readonly error: Error }; - -/** - * Internal record of a single {@link AsyncSchedulerProcessor.run} call. - * - * A {@link Run} is purely declarative: its termination predicate - * ({@link evaluate}) is a pure function over the processor's observable state - * (`scheduler.now`, `executedTotal`, the next task time, the token state). - * The processor never mutates a run; it only inspects it and, when done, - * resolves or rejects its {@link promise}. - */ -class Run { - - private static _idCounter = 0; - public readonly id = ++Run._idCounter; - - public readonly promise: Promise; - private _resolve!: () => void; - private _reject!: (e: Error) => void; - private _settled = false; - public get settled(): boolean { return this._settled; } - - constructor( - public readonly options: RunOptions, - public readonly tasksExecutedAtStart: number, - public readonly effectiveMaxTasks: number, - ) { - this.promise = new Promise((res, rej) => { - this._resolve = res; - this._reject = rej; - }); - } - - settle(error?: Error): void { - if (this._settled) { return; } - this._settled = true; - if (error) { this._reject(error); } else { this._resolve(); } - } - - evaluate(scheduler: TimeTravelScheduler, executedTotal: number, peekNextTime: TimeOffset | undefined, makeOverflowError: () => Error): RunStatus { - const localExecuted = executedTotal - this.tasksExecutedAtStart; - if (localExecuted >= this.effectiveMaxTasks && scheduler.hasScheduledTasks) { - return { error: makeOverflowError() }; - } - - if (this.options.virtualDeadline !== undefined) { - if (scheduler.now >= this.options.virtualDeadline) { return 'done'; } - // Virtual time can only advance by executing tasks. If no scheduled - // task can advance time up to the deadline, the run is effectively - // done (otherwise the loop would idle-wait forever). - if (peekNextTime === undefined || peekNextTime > this.options.virtualDeadline) { return 'done'; } - } - - if (this.options.token === undefined) { - return scheduler.hasScheduledTasks ? 'continue' : 'done'; - } - - if (this.options.token.isCancellationRequested && !scheduler.hasScheduledTasks) { return 'done'; } - return 'continue'; - } - - describe(executedTotal: number, startTime: TimeOffset): string { - const parts: string[] = [`#${this.id}`]; - if (this.options.token) { - parts.push(this.options.token.isCancellationRequested ? 'token=cancelled' : 'token=pending'); - } - if (this.options.virtualDeadline !== undefined) { - const delta = this.options.virtualDeadline - startTime; - const sign = delta < 0 ? '-' : '+'; - parts.push(`virtualDeadline=${sign}${Math.abs(delta)}ms`); - } - const localExecuted = executedTotal - this.tasksExecutedAtStart; - parts.push(`executed=${localExecuted}/${this.effectiveMaxTasks}`); - return parts.join(' '); - } -} - /** - * Drives a {@link TimeTravelScheduler} from the real microtask/macrotask queue, - * yielding back to real time between virtual tasks so that promise callbacks - * can run and (re)schedule virtual tasks before the next one is executed. - * - * # Invariants - * - * 1. **Single physical loop.** At any moment at most one async loop iterates - * the virtual queue. Concurrent {@link run} calls compose by registering - * additional {@link Run}s on the same loop. - * - * 2. **Yield-then-execute.** Each loop iteration yields to real time before - * executing the next virtual task. No two virtual tasks run back-to-back - * without a yield. Termination is re-evaluated after each yield. - * - * 3. **Termination is per-run and pure.** Each run carries its own termination - * options ({@link RunOptions}); deadlines, tokens and maxTasks are never - * stored as mutable processor state. This is what makes parallel runs with - * different deadlines compose cleanly. - * - * 4. **Loop respects the strictest deadline.** The loop never advances virtual - * time past `min(virtualDeadline of all active runs)`. Past-deadline runs - * are settled before the loop attempts the next yield. - * - * 5. **Idle waits are explicit and breakable.** When the queue is empty (or - * the next task is past every active deadline) the loop awaits a single - * composite signal: a new task being scheduled, a run being added, or a - * token being cancelled. It never busy-loops. - * - * 6. **Errors propagate via promise rejection.** A throwing virtual task or a - * {@link RunOptions.maxTasks} overflow rejects the relevant run(s). No - * sticky `_lastError` flag is left for the next caller. - * - * 7. **Disposal settles all runs.** {@link dispose} rejects every active run - * with a disposal error and lets the loop drain naturally. + * @deprecated The contents of this file have moved to + * `./virtualScheduling/index.js`. This re-export is kept for backwards + * compatibility and will be removed once all callers have migrated. + * + * Notes for migration: + * - `TimeTravelScheduler` is now {@link VirtualClock} (same constructor). + * - `AsyncSchedulerProcessor` is now {@link VirtualTimeProcessor}; its + * constructor takes an explicit {@link Embedding}, and {@link Run.options} + * use `until` (a {@link TerminationPolicy}) instead of the implicit + * "drain queue" behaviour. See {@link runWithFakedTimers} for a + * drop-in helper. + * - `originalGlobalValues` is now {@link realTimeApi}. */ -export class AsyncSchedulerProcessor extends Disposable { - - private readonly _runs = new Map(); - private readonly _history: ScheduledTask[] = []; - private _executedTotal = 0; - - private _loopRunning = false; - private _wakeup: (() => void) | undefined; - - private readonly _defaultMaxTasks: number; - private readonly _useSetImmediate: boolean; - private readonly _realTimeApi: TimeApi; - private readonly _startTime: TimeOffset; - - public get history(): readonly ScheduledTask[] { return this._history; } - - constructor( - private readonly scheduler: TimeTravelScheduler, - options?: { useSetImmediate?: boolean; maxTaskCount?: number; realTimeApi?: TimeApi } - ) { - super(); - this._defaultMaxTasks = options?.maxTaskCount ?? 100; - this._useSetImmediate = options?.useSetImmediate ?? false; - this._realTimeApi = options?.realTimeApi ?? originalGlobalValues; - this._startTime = scheduler.now; - - this._register({ dispose: () => this._disposeAllRuns() }); - } - - /** - * Start a run with the given termination policy. - * - * - With no options: resolves when the virtual queue is empty. - * - With `token`: resolves when the token is cancelled AND the queue is - * drained. Tasks scheduled before cancellation are still processed. - * - With `virtualDeadline`: resolves when virtual time reaches the deadline, - * or when no scheduled task remains within it. - * - With `maxTasks`: rejects if the run executes more than that many virtual - * tasks before its other termination conditions are satisfied. - * - * Multiple parallel runs share the same processing loop; each resolves - * independently when its own predicate fires. - */ - run(options: RunOptions = {}): Promise { - return this._startRun(options); - } - - private _startRun(options: RunOptions): Promise { - const run = new Run(options, this._executedTotal, options.maxTasks ?? this._defaultMaxTasks); - const cleanup = new DisposableStore(); - if (options.token) { - cleanup.add(options.token.onCancellationRequested(() => this._wake())); - } - this._runs.set(run, cleanup); - this._wake(); - void this._ensureLoopRunning(); - return run.promise; - } - - private _settleRun(run: Run, error?: Error): void { - const cleanup = this._runs.get(run); - if (!cleanup) { return; } - this._runs.delete(run); - cleanup.dispose(); - run.settle(error); - } - - private _disposeAllRuns(): void { - const err = new Error('AsyncSchedulerProcessor disposed'); - for (const run of [...this._runs.keys()]) { - this._settleRun(run, err); - } - this._wake(); - } - - private _wake(): void { - const w = this._wakeup; - this._wakeup = undefined; - w?.(); - } - - private async _ensureLoopRunning(): Promise { - if (this._loopRunning) { return; } - this._loopRunning = true; - try { - await this._loop(); - } finally { - this._loopRunning = false; - } - } - - private async _loop(): Promise { - while (true) { - this._settleFinishedRuns(); - if (this._runs.size === 0) { return; } - - const next = this.scheduler.peekNext(); - const minDeadline = this._minDeadline(); - - if (!next || next.time > minDeadline) { - // Nothing actionable. Wait for a new task to be scheduled, a - // token to be cancelled, a new run to be added, or disposal. - await this._waitForChange(); - continue; - } - - // Invariant 2: yield to real time before each virtual execution. - await this._yieldToReal(next); - - // Re-check after yielding: anything could have changed. - this._settleFinishedRuns(); - if (this._runs.size === 0) { return; } - - const stillNext = this.scheduler.peekNext(); - if (!stillNext || stillNext.time > this._minDeadline()) { continue; } - - // Check per-run maxTaskDepth: if this task's causal depth exceeds - // any active run's limit, reject that run before executing. - const taskDepth = stillNext.trace?.depth ?? 0; - let overflowed = false; - for (const run of [...this._runs.keys()]) { - const limit = run.options.maxTaskDepth; - if (limit !== undefined && taskDepth > limit) { - this._settleRun(run, this._buildDepthOverflowError(run, taskDepth)); - overflowed = true; - } - } - if (overflowed) { continue; } - - try { - // Execute the task under its causal trace so that its body - // and subsequent microtask drain observe it. `runAsHandler` - // keeps the trace in place across the microtask drain by - // scheduling a seq-guarded reset on the next real-time tick. - TraceContext.instance.runAsHandler(stillNext.trace ?? ROOT_TRACE, () => { - const executed = this.scheduler.runNext(); - if (executed) { - this._history.push(executed); - this._executedTotal++; - } - }, this._realTimeApi); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - // We can't tell which run "owned" the throwing task. Reject all - // active runs so the failure is observed exactly once per caller. - for (const run of [...this._runs.keys()]) { - this._settleRun(run, err); - } - } - } - } - - private _settleFinishedRuns(): void { - const peekNextTime = this.scheduler.peekNext()?.time; - for (const run of [...this._runs.keys()]) { - if (run.settled) { continue; } - const status = run.evaluate(this.scheduler, this._executedTotal, peekNextTime, () => this._buildOverflowError(run)); - if (status === 'done') { - this._settleRun(run); - } else if (typeof status === 'object') { - this._settleRun(run, status.error); - } - } - } - - private _minDeadline(): TimeOffset { - let m = Number.MAX_SAFE_INTEGER; - for (const run of this._runs.keys()) { - if (run.options.virtualDeadline !== undefined && run.options.virtualDeadline < m) { - m = run.options.virtualDeadline; - } - } - return m; - } - - private _waitForChange(): Promise { - return new Promise(resolve => { - const store = new DisposableStore(); - const fire = () => { - if (this._wakeup === fire) { this._wakeup = undefined; } - store.dispose(); - resolve(); - }; - this._wakeup = fire; - store.add(this.scheduler.onTaskScheduled(fire)); - }); - } - - private _yieldToReal(next: ScheduledTask): Promise { - return new Promise(resolve => { - // Drain microtasks first so promises chained to the previous task - // can settle and schedule virtual tasks before the next runs. - Promise.resolve().then(() => { - if (next.useRealAnimationFrame && this._realTimeApi.requestAnimationFrame) { - this._realTimeApi.requestAnimationFrame(() => resolve()); - } else if (this._useSetImmediate && this._realTimeApi.setImmediate) { - this._realTimeApi.setImmediate(() => resolve()); - } else if (setTimeout0IsFaster) { - setTimeout0(() => resolve()); - } else { - this._realTimeApi.setTimeout(() => resolve()); - } - }); - }); - } - - private _buildOverflowError(run: Run): Error { - const localExecuted = this._executedTotal - run.tasksExecutedAtStart; - const limit = run.effectiveMaxTasks; - return new Error( - `[AsyncSchedulerProcessor] Run #${run.id} exceeded maxTasks (${limit}) — ` + - `executed ${localExecuted} virtual task(s) and the queue is still not empty.\n\n` + - this.toString() - ); - } - - private _buildDepthOverflowError(run: Run, taskDepth: number): Error { - const limit = run.options.maxTaskDepth!; - return new Error( - `[AsyncSchedulerProcessor] Run #${run.id} exceeded maxTaskDepth (${limit}) — ` + - `next task has causal depth ${taskDepth}. This usually indicates ` + - `a runaway self-rescheduling timer.\n\n` + - this.toString() - ); - } - - /** - * A debug-friendly snapshot of the processor: virtual time, active runs, - * recent history (with stack traces) and currently queued tasks. - */ - override toString(): string { - const queued = this.scheduler.getScheduledTasks(); - const lines: string[] = []; - const fmt = (task: ScheduledTask, indent: string) => formatScheduledTask(task, indent, this._startTime); - - lines.push( - `AsyncSchedulerProcessor { ` + - `now=+${this.scheduler.now - this._startTime}ms, ` + - `executed=${this._executedTotal}, ` + - `queued=${queued.length}, ` + - `runs=${this._runs.size}, ` + - `loopRunning=${this._loopRunning} }` - ); - - if (this._runs.size > 0) { - lines.push(''); - lines.push('Active runs:'); - for (const run of this._runs.keys()) { - lines.push(` ${run.describe(this._executedTotal, this._startTime)}`); - } - } - - const HISTORY_LIMIT = 10; - if (this._history.length > 0) { - const recent = this._history.slice(-HISTORY_LIMIT); - lines.push(''); - const omitted = this._history.length - recent.length; - lines.push(`History (${recent.length}${omitted > 0 ? ` of ${this._history.length}` : ''}):`); - for (const t of recent) { - lines.push(fmt(t, ' ')); - } - } - - if (queued.length > 0) { - const QUEUE_LIMIT = 20; - const shown = queued.slice(0, QUEUE_LIMIT); - lines.push(''); - lines.push(`Queued (${queued.length}):`); - for (const t of shown) { - lines.push(fmt(t, ' ')); - } - if (queued.length > shown.length) { - lines.push(` ... and ${queued.length - shown.length} more`); - } - } - - return lines.join('\n'); - } -} - -function formatScheduledTask(task: ScheduledTask, indent: string, startTime: TimeOffset): string { - const delta = task.time - startTime; - const sign = delta < 0 ? '-' : '+'; - const time = `${sign}${Math.abs(delta)}ms`.padStart(8); - const head = `${indent}[${time}] ${task.source.toString()}`; - const lines: string[] = [head]; - if (task.trace) { - lines.push(`${indent} trace: ${task.trace.describe()}`); - } - const stack = task.source.stackTrace; - if (stack) { - const stackLines = stack.split('\n').map(l => l.trim()).filter(l => l.length > 0); - // Drop the leading "Error" line that `new Error().stack` produces, - // then keep a few useful frames. - const frames = (stackLines[0]?.startsWith('Error') ? stackLines.slice(1) : stackLines).slice(0, 5); - for (const f of frames) { - lines.push(`${indent} ${f}`); - } - } - return lines.join('\n'); -} - -export async function runWithFakedTimers(options: { startTime?: number; useFakeTimers?: boolean; useSetImmediate?: boolean; maxTaskCount?: number }, fn: () => Promise): Promise { - const useFakeTimers = options.useFakeTimers === undefined ? true : options.useFakeTimers; - if (!useFakeTimers) { - return fn(); - } - - const scheduler = new TimeTravelScheduler(options.startTime ?? 0); - const schedulerProcessor = new AsyncSchedulerProcessor(scheduler, { useSetImmediate: options.useSetImmediate, maxTaskCount: options.maxTaskCount }); - const globalInstallDisposable = scheduler.installGlobally(); - - // Start processing. With a token, run() keeps processing tasks until the - // token is cancelled and the queue is drained, so tasks scheduled during - // fn() are processed concurrently. - const cts = new CancellationTokenSource(); - const runPromise = schedulerProcessor.run({ token: cts.token }); - - let didThrow = true; - let result: T; - try { - result = await fn(); - didThrow = false; - } finally { - globalInstallDisposable.dispose(); - - // Signal that fn() is done: run() should drain the queue (for success) - // or stop immediately (for error) and then resolve. - // Since the global override is already disposed, no more tasks will be - // scheduled during the final drain. - cts.cancel(); - - try { - if (!didThrow) { - await runPromise; - } else { - // Avoid an unhandled rejection when disposal below rejects the run. - runPromise.catch(() => { /* swallowed: fn() already failed */ }); - } - } finally { - cts.dispose(); - schedulerProcessor.dispose(); - } - } - - return result; -} - -export function captureGlobalTimeApi(): TimeApi { - return { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - setInterval: globalThis.setInterval.bind(globalThis), - clearInterval: globalThis.clearInterval.bind(globalThis), - setImmediate: globalThis.setImmediate?.bind(globalThis), - clearImmediate: globalThis.clearImmediate?.bind(globalThis), - requestAnimationFrame: globalThis.requestAnimationFrame?.bind(globalThis), - cancelAnimationFrame: globalThis.cancelAnimationFrame?.bind(globalThis), - Date: globalThis.Date, - originalFunctions: { - setTimeout: globalThis.setTimeout, - clearTimeout: globalThis.clearTimeout, - setInterval: globalThis.setInterval, - clearInterval: globalThis.clearInterval, - setImmediate: globalThis.setImmediate, - clearImmediate: globalThis.clearImmediate, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - Date: globalThis.Date, - }, - }; -} - -export const originalGlobalValues: TimeApi = captureGlobalTimeApi(); -// Expose the real setTimeout for the component explorer runtime, which needs true time -// even when virtual time is installed for fixtures. -// eslint-disable-next-line local/code-no-any-casts -(originalGlobalValues.setTimeout as any).originalFn = originalGlobalValues.setTimeout; - -export interface CreateVirtualTimeApiOptions { - fakeRequestAnimationFrame?: boolean; -} - -export function createVirtualTimeApi(scheduler: Scheduler, options?: CreateVirtualTimeApiOptions): TimeApi { - function virtualSetTimeout(handler: TimerHandler, timeout: number = 0): IDisposable { - if (typeof handler === 'string') { - throw new Error('String handler args should not be used and are not supported'); - } - const stackTrace = new Error().stack; - const trace = TraceContext.instance.currentTrace().child(`setTimeout(${timeout}ms)`, stackTrace); - return scheduler.schedule({ - time: scheduler.now + timeout, - run: () => { handler(); }, - source: { - toString() { return 'setTimeout'; }, - stackTrace, - }, - trace, - }); - } - - function virtualClearTimeout(timeoutId: unknown): void { - if (typeof timeoutId === 'object' && timeoutId && 'dispose' in timeoutId) { - (timeoutId as IDisposable).dispose(); - } - } - - function virtualSetInterval(handler: TimerHandler, interval: number): IDisposable { - if (typeof handler === 'string') { - throw new Error('String handler args should not be used and are not supported'); - } - const validatedHandler = handler; - let iterCount = 0; - const stackTrace = new Error().stack; - const baseTrace = TraceContext.instance.currentTrace().child(`setInterval(${interval}ms)`, stackTrace); - let disposed = false; - let lastDisposable: IDisposable; - - function schedule(): void { - iterCount++; - const curIter = iterCount; - lastDisposable = scheduler.schedule({ - time: scheduler.now + interval, - run() { - if (!disposed) { - schedule(); - validatedHandler(); - } - }, - source: { - toString() { return `setInterval (iteration ${curIter})`; }, - stackTrace, - }, - trace: baseTrace.child(`tick #${curIter}`), - }); - } - schedule(); - - return { - dispose: () => { - if (disposed) { return; } - disposed = true; - lastDisposable.dispose(); - } - }; - } - - function virtualClearInterval(intervalId: unknown): void { - if (typeof intervalId === 'object' && intervalId && 'dispose' in intervalId) { - (intervalId as IDisposable).dispose(); - } - } - - const OriginalDate = globalThis.Date; - function SchedulerDate(this: any, ...args: any): any { - if (!(this instanceof SchedulerDate)) { - return new OriginalDate(scheduler.now).toString(); - } - if (args.length === 0) { - return new OriginalDate(scheduler.now); - } - // eslint-disable-next-line local/code-no-any-casts - return new (OriginalDate as any)(...args); - } - for (const prop in OriginalDate) { - if (OriginalDate.hasOwnProperty(prop)) { - // eslint-disable-next-line local/code-no-any-casts - (SchedulerDate as any)[prop] = (OriginalDate as any)[prop]; - } - } - SchedulerDate.now = function now() { return scheduler.now; }; - SchedulerDate.toString = function toString() { return OriginalDate.toString(); }; - SchedulerDate.prototype = OriginalDate.prototype; - SchedulerDate.parse = OriginalDate.parse; - SchedulerDate.UTC = OriginalDate.UTC; - SchedulerDate.prototype.toUTCString = OriginalDate.prototype.toUTCString; - - /* eslint-disable local/code-no-any-casts */ - const api: TimeApi = { - setTimeout: virtualSetTimeout as any, - clearTimeout: virtualClearTimeout as any, - setInterval: virtualSetInterval as any, - clearInterval: virtualClearInterval as any, - Date: SchedulerDate as any, - }; - /* eslint-enable local/code-no-any-casts */ - - // Expose the real setTimeout as `originalFn` on the virtual one. The component-explorer - // host's polling loop reads `globalThis.setTimeout.originalFn` to escape virtual time - // when waiting for renders to settle. Without this, the host's poll re-arms inside - // virtual time and triggers the AsyncSchedulerProcessor's depth-overflow guard. - // eslint-disable-next-line local/code-no-any-casts - (api.setTimeout as any).originalFn = originalGlobalValues.setTimeout; - - if (options?.fakeRequestAnimationFrame) { - let rafIdCounter = 0; - const rafDisposables = new Map(); - - api.requestAnimationFrame = (callback: (time: number) => void) => { - const id = ++rafIdCounter; - const stackTrace = new Error().stack; - const trace = TraceContext.instance.currentTrace().child('requestAnimationFrame', stackTrace); - // Advance virtual time by 16ms (~60fps). The task is marked with - // useRealAnimationFrame so the AsyncSchedulerProcessor uses a real - // browser rAF to schedule its execution, ensuring the browser - // reflows before the callback runs (so DOM measurements like - // offsetHeight return accurate values). - const disposable = scheduler.schedule({ - time: scheduler.now + 16, - useRealAnimationFrame: true, - run: () => { - rafDisposables.delete(id); - callback(scheduler.now); - }, - source: { - toString() { return 'requestAnimationFrame'; }, - stackTrace, - }, - trace, - }); - rafDisposables.set(id, disposable); - return id; - }; - - api.cancelAnimationFrame = (id: number) => { - const disposable = rafDisposables.get(id); - if (disposable) { - disposable.dispose(); - rafDisposables.delete(id); - } - }; - } - - return api; -} - -export function pushGlobalTimeApi(api: TimeApi): IDisposable { - const captured = captureGlobalTimeApi(); - - // eslint-disable-next-line local/code-no-any-casts - globalThis.setTimeout = api.setTimeout as any; - // eslint-disable-next-line local/code-no-any-casts - globalThis.clearTimeout = api.clearTimeout as any; - // eslint-disable-next-line local/code-no-any-casts - globalThis.setInterval = api.setInterval as any; - // eslint-disable-next-line local/code-no-any-casts - globalThis.clearInterval = api.clearInterval as any; - globalThis.Date = api.Date; - - if (api.requestAnimationFrame) { - globalThis.requestAnimationFrame = api.requestAnimationFrame; - } - if (api.cancelAnimationFrame) { - globalThis.cancelAnimationFrame = api.cancelAnimationFrame; - } - - return { - dispose: () => { - Object.assign(globalThis, captured.originalFunctions ?? captured); - } - }; -} - -export function createLoggingTimeApi( - underlying: TimeApi, - onCall: (name: string, stack: string | undefined, handler?: TimerHandler) => void, -): TimeApi { - return { - setTimeout(handler: TimerHandler, timeout?: number) { - onCall('setTimeout', new Error().stack, handler); - return underlying.setTimeout(handler, timeout); - }, - clearTimeout(id: unknown) { - return underlying.clearTimeout(id); - }, - setInterval(handler: TimerHandler, interval: number) { - onCall('setInterval', new Error().stack, handler); - return underlying.setInterval(handler, interval); - }, - clearInterval(id: unknown) { - return underlying.clearInterval(id); - }, - setImmediate: underlying.setImmediate ? (handler: () => void) => { - onCall('setImmediate', new Error().stack, handler); - return underlying.setImmediate!(handler); - } : undefined, - clearImmediate: underlying.clearImmediate, - requestAnimationFrame: underlying.requestAnimationFrame ? (callback: (time: number) => void) => { - onCall('requestAnimationFrame', new Error().stack, callback as TimerHandler); - return underlying.requestAnimationFrame!(callback); - } : undefined, - cancelAnimationFrame: underlying.cancelAnimationFrame, - Date: underlying.Date, - }; -} - -interface PriorityQueue { - length: number; - add(value: T): void; - remove(value: T): void; - - removeMin(): T | undefined; - getMin(): T | undefined; - toSortedArray(): T[]; -} - -class SimplePriorityQueue implements PriorityQueue { - private isSorted = false; - private items: T[]; - - constructor(items: T[], private readonly compare: (a: T, b: T) => number) { - this.items = items; - } - - get length(): number { - return this.items.length; - } - - add(value: T): void { - this.items.push(value); - this.isSorted = false; - } - - remove(value: T): void { - const idx = this.items.indexOf(value); - if (idx !== -1) { - this.items.splice(idx, 1); - this.isSorted = false; - } - } - - removeMin(): T | undefined { - this.ensureSorted(); - return this.items.shift(); - } - - getMin(): T | undefined { - this.ensureSorted(); - return this.items[0]; - } - - toSortedArray(): T[] { - this.ensureSorted(); - return [...this.items]; - } - private ensureSorted() { - if (!this.isSorted) { - this.items.sort(this.compare); - this.isSorted = true; - } - } -} +export { + captureGlobalTimeApi, + createLoggingTimeApi, + createVirtualTimeApi, + pushGlobalTimeApi, + realTimeApi as originalGlobalValues, + runWithFakedTimers, + VirtualClock as TimeTravelScheduler, +} from './virtualScheduling/index.js'; + +export type { + CreateVirtualTimeApiOptions, + RunWithFakedTimersOptions, + TimeApi, +} from './virtualScheduling/index.js'; diff --git a/src/vs/base/test/common/traceableTimeApi.test.ts b/src/vs/base/test/common/traceableTimeApi.test.ts deleted file mode 100644 index 1629b571c1c686..00000000000000 --- a/src/vs/base/test/common/traceableTimeApi.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; -import { - createTraceRoot, - Trace, - TraceContext, -} from './traceableTimeApi.js'; -import { AsyncSchedulerProcessor, captureGlobalTimeApi, createVirtualTimeApi, TimeTravelScheduler } from './timeTravelScheduler.js'; -import { buildHistoryFromTasks, renderSwimlanes } from './executionGraph.js'; -import { DisposableStore } from '../../common/lifecycle.js'; - -/** Stable serialisation of a trace for snapshot assertions. */ -function traceInfo(t: Trace): { labels: string[]; rootLabel: string; depth: number } { - const labels: string[] = []; - for (let c: Trace | undefined = t; c; c = c.parent) { labels.push(c.label); } - return { labels, rootLabel: t.root.label, depth: t.depth }; -} - -suite('traceableTimeApi', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - teardown(() => TraceContext.instance._resetForTesting()); - - test('Trace.describe builds causal chain from leaf to root', () => { - const root = createTraceRoot('fixture'); - const t1 = root.child('setTimeout(100ms)'); - const t2 = t1.child('await continuation'); - assert.deepStrictEqual(traceInfo(t2), { - labels: ['await continuation', 'setTimeout(100ms)', 'fixture'], - rootLabel: 'fixture', - depth: 2, - }); - }); - - test('runWithTrace installs and restores synchronously; supports nesting', () => { - const a = createTraceRoot('a'); - const b = createTraceRoot('b'); - const observations: string[] = []; - observations.push(TraceContext.instance.currentTrace().label); - TraceContext.instance.runWithTrace(a, () => { - observations.push(TraceContext.instance.currentTrace().label); - TraceContext.instance.runWithTrace(b, () => { - observations.push(TraceContext.instance.currentTrace().label); - }); - observations.push(TraceContext.instance.currentTrace().label); - }); - observations.push(TraceContext.instance.currentTrace().label); - assert.deepStrictEqual(observations, ['', 'a', 'b', 'a', '']); - }); - - test('runAsHandler throws on sync re-entry', () => { - const realTime = captureGlobalTimeApi(); - const a = createTraceRoot('a'); - const b = createTraceRoot('b'); - assert.throws( - () => TraceContext.instance.runAsHandler(a, () => TraceContext.instance.runAsHandler(b, () => { }, realTime), realTime), - /re-entrant runAsHandler/, - ); - }); - - test('runAsHandler leaks trace across awaited microtasks', async () => { - const realTime = captureGlobalTimeApi(); - const fixtureRoot = createTraceRoot('fixture'); - const observations: string[] = []; - - await TraceContext.instance.runAsHandler(fixtureRoot, async () => { - observations.push(TraceContext.instance.currentTrace().label); - await Promise.resolve(); - observations.push(TraceContext.instance.currentTrace().label); - await Promise.resolve().then(() => Promise.resolve()); - observations.push(TraceContext.instance.currentTrace().label); - }, realTime); - - assert.deepStrictEqual(observations, ['fixture', 'fixture', 'fixture']); - }); - - test('tracing time api tags setTimeout, fires callback under captured trace', async () => { - const realTime = captureGlobalTimeApi(); - const tracing = TraceContext.instance.createTracingTimeApi(realTime, realTime); - const root = createTraceRoot('root'); - - const { promise, resolve } = deferred(); - TraceContext.instance.runAsHandler(root, () => { - tracing.setTimeout(() => resolve(TraceContext.instance.currentTrace()), 0); - }, realTime); - - const observed = await promise; - assert.deepStrictEqual(traceInfo(observed), { - labels: ['setTimeout(0ms)', 'root'], - rootLabel: 'root', - depth: 1, - }); - }); - - test('tracing time api: nested setTimeout preserves full causal chain', async () => { - const realTime = captureGlobalTimeApi(); - const tracing = TraceContext.instance.createTracingTimeApi(realTime, realTime); - const root = createTraceRoot('root'); - - const { promise, resolve } = deferred(); - TraceContext.instance.runAsHandler(root, () => { - tracing.setTimeout(() => { - tracing.setTimeout(() => resolve(TraceContext.instance.currentTrace()), 0); - }, 0); - }, realTime); - - const observed = await promise; - assert.deepStrictEqual(traceInfo(observed), { - labels: ['setTimeout(0ms)', 'setTimeout(0ms)', 'root'], - rootLabel: 'root', - depth: 2, - }); - }); - - test('setInterval: each tick gets a fresh child trace', async () => { - const realTime = captureGlobalTimeApi(); - const tracing = TraceContext.instance.createTracingTimeApi(realTime, realTime); - const root = createTraceRoot('root'); - - const observed: Trace[] = []; - const { promise, resolve } = deferred(); - let id: unknown; - TraceContext.instance.runAsHandler(root, () => { - id = tracing.setInterval(() => { - observed.push(TraceContext.instance.currentTrace()); - if (observed.length === 3) { tracing.clearInterval(id); resolve(); } - }, 5); - }, realTime); - - await promise; - assert.deepStrictEqual(observed.map(t => traceInfo(t)), [ - { labels: ['tick #1', 'setInterval(5ms)', 'root'], rootLabel: 'root', depth: 2 }, - { labels: ['tick #2', 'setInterval(5ms)', 'root'], rootLabel: 'root', depth: 2 }, - { labels: ['tick #3', 'setInterval(5ms)', 'root'], rootLabel: 'root', depth: 2 }, - ]); - }); - - test('concurrent runAsHandler via setTimeout 0: traces do not leak across handlers', async () => { - const realTime = captureGlobalTimeApi(); - const tracing = TraceContext.instance.createTracingTimeApi(realTime, realTime); - const a = createTraceRoot('a'); - const b = createTraceRoot('b'); - - const { promise: doneA, resolve: resA } = deferred(); - const { promise: doneB, resolve: resB } = deferred(); - TraceContext.instance.runAsHandler(a, () => { - tracing.setTimeout(() => resA(TraceContext.instance.currentTrace()), 0); - }, realTime); - TraceContext.instance.runAsHandler(b, () => { - tracing.setTimeout(() => resB(TraceContext.instance.currentTrace()), 0); - }, realTime); - - const [tA, tB] = await Promise.all([doneA, doneB]); - assert.deepStrictEqual({ - aRoot: tA.root.label, - aLabels: traceInfo(tA).labels, - bRoot: tB.root.label, - bLabels: traceInfo(tB).labels, - }, { - aRoot: 'a', - aLabels: ['setTimeout(0ms)', 'a'], - bRoot: 'b', - bLabels: ['setTimeout(0ms)', 'b'], - }); - }); - - test('buildHistoryFromTasks adapter: scheduler history feeds the renderer', async () => { - const startTime = 1000; - const store = new DisposableStore(); - const scheduler = new TimeTravelScheduler(startTime); - const p = store.add(new AsyncSchedulerProcessor(scheduler, { maxTaskCount: 100 })); - const vt = createVirtualTimeApi(scheduler, { fakeRequestAnimationFrame: true }); - - const rootA = createTraceRoot('A'); - const rootB = createTraceRoot('B'); - - // A: setTimeout(+0) spawns rAF(+16) and setTimeout(+50); rAF(+16) → rAF(+32). - TraceContext.instance.runWithTrace(rootA, () => { - vt.setTimeout(() => { - vt.requestAnimationFrame!(() => { - vt.requestAnimationFrame!(() => { /* A deep paint */ }); - }); - vt.setTimeout(() => { /* A delayed work */ }, 50); - }, 0); - }); - - // B: setTimeout(+10) → rAF(+26) → setTimeout(+46). - TraceContext.instance.runWithTrace(rootB, () => { - vt.setTimeout(() => { - vt.requestAnimationFrame!(() => { - vt.setTimeout(() => { /* B work */ }, 20); - }); - }, 10); - }); - - await p.run(); - - const history = buildHistoryFromTasks(p.history, startTime); - assert.deepStrictEqual( - { - rootLabels: history.roots.map(r => r.label), - events: history.events.map(e => ({ - time: e.time, - label: e.label, - root: e.root.label, - parent: e.parent ? `${e.parent.label}@+${e.parent.time}` : undefined, - })), - }, - { - rootLabels: ['A', 'B'], - events: [ - { time: 0, label: 'setTimeout', root: 'A', parent: undefined }, - { time: 10, label: 'setTimeout', root: 'B', parent: undefined }, - { time: 16, label: 'requestAnimationFrame', root: 'A', parent: 'setTimeout@+0' }, - { time: 26, label: 'requestAnimationFrame', root: 'B', parent: 'setTimeout@+10' }, - { time: 32, label: 'requestAnimationFrame', root: 'A', parent: 'requestAnimationFrame@+16' }, - { time: 46, label: 'setTimeout', root: 'B', parent: 'requestAnimationFrame@+26' }, - { time: 50, label: 'setTimeout', root: 'A', parent: 'setTimeout@+0' }, - ], - }, - ); - - // Sanity check: the renderer still works on adapter output. Output - // correctness is covered by `executionGraph.test.ts`. - assert.ok(renderSwimlanes(history).length > 0); - - store.dispose(); - }); - - test('complex graph: async/await + setTimeout + setInterval + rAF interleave with preserved causality', async () => { - const startTime = 1000; - const store = new DisposableStore(); - const scheduler = new TimeTravelScheduler(startTime); - const p = store.add(new AsyncSchedulerProcessor(scheduler, { maxTaskCount: 200 })); - const vt = createVirtualTimeApi(scheduler, { fakeRequestAnimationFrame: true }); - const log = TraceContext.instance.log.bind(TraceContext.instance); - - const rootA = createTraceRoot('A'); - const rootB = createTraceRoot('B'); - - // A: setTimeout(+1) runs an async handler that, after a microtask, - // fans out into rAF (+16) and setInterval(10ms). The rAF callback - // itself awaits a microtask before scheduling a setTimeout(+20). - // The interval ticks twice and then clears itself. - TraceContext.instance.runWithTrace(rootA, () => { - vt.setTimeout(async () => { - log('A:start'); - await Promise.resolve(); - log('A:after-await'); - vt.requestAnimationFrame!(async () => { - log('A:rAF'); - await Promise.resolve(); - vt.setTimeout(() => log('A:post-rAF'), 20); - }); - let ticks = 0; - const id = vt.setInterval(() => { - ticks++; - log(`A:tick#${ticks}`); - if (ticks === 2) { vt.clearInterval(id); } - }, 10); - }, 1); - }); - - // B: setTimeout(+3) → rAF (+19) → setTimeout(+39). Starts close to A - // so the two roots' events interleave on the timeline. - TraceContext.instance.runWithTrace(rootB, () => { - vt.setTimeout(() => { - log('B:start'); - vt.requestAnimationFrame!(() => { - log('B:rAF'); - vt.setTimeout(() => log('B:post-rAF'), 20); - }); - }, 3); - }); - - await p.run(); - - const history = buildHistoryFromTasks(p.history, startTime, TraceContext.instance.takeLog()); - assert.deepStrictEqual( - { - rootLabels: history.roots.map(r => r.label), - events: history.events.map(e => ({ - time: e.time, - label: e.label, - root: e.root.label, - parent: e.parent ? `${e.parent.label}@+${e.parent.time}` : undefined, - })), - }, - { - rootLabels: ['A', 'B'], - events: [ - { time: 1, label: 'setTimeout', root: 'A', parent: undefined }, - { time: 1, label: 'log: A:start', root: 'A', parent: 'setTimeout@+1' }, - { time: 1, label: 'log: A:after-await', root: 'A', parent: 'setTimeout@+1' }, - { time: 3, label: 'setTimeout', root: 'B', parent: undefined }, - { time: 3, label: 'log: B:start', root: 'B', parent: 'setTimeout@+3' }, - { time: 11, label: 'setInterval (iteration 1)', root: 'A', parent: 'setTimeout@+1' }, - { time: 11, label: 'log: A:tick#1', root: 'A', parent: 'setInterval (iteration 1)@+11' }, - { time: 17, label: 'requestAnimationFrame', root: 'A', parent: 'setTimeout@+1' }, - { time: 17, label: 'log: A:rAF', root: 'A', parent: 'requestAnimationFrame@+17' }, - { time: 19, label: 'requestAnimationFrame', root: 'B', parent: 'setTimeout@+3' }, - { time: 19, label: 'log: B:rAF', root: 'B', parent: 'requestAnimationFrame@+19' }, - { time: 21, label: 'setInterval (iteration 2)', root: 'A', parent: 'setTimeout@+1' }, - { time: 21, label: 'log: A:tick#2', root: 'A', parent: 'setInterval (iteration 2)@+21' }, - { time: 37, label: 'setTimeout', root: 'A', parent: 'requestAnimationFrame@+17' }, - { time: 37, label: 'log: A:post-rAF', root: 'A', parent: 'setTimeout@+37' }, - { time: 39, label: 'setTimeout', root: 'B', parent: 'requestAnimationFrame@+19' }, - { time: 39, label: 'log: B:post-rAF', root: 'B', parent: 'setTimeout@+39' }, - ], - }, - ); - - // Sanity check: the renderer accepts the adapter output. - assert.ok(renderSwimlanes(history).length > 0); - - store.dispose(); - }); -}); - -function deferred(): { promise: Promise; resolve: (v: T) => void; reject: (e: unknown) => void } { - let resolve!: (v: T) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); - return { promise, resolve, reject }; -} diff --git a/src/vs/base/test/common/traceableTimeApi.ts b/src/vs/base/test/common/traceableTimeApi.ts index 328539184455ee..40d8c5d81d914c 100644 --- a/src/vs/base/test/common/traceableTimeApi.ts +++ b/src/vs/base/test/common/traceableTimeApi.ts @@ -3,350 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BugIndicatingError } from '../../common/errors.js'; -import type { TimeApi } from './timeTravelScheduler.js'; - -/** - * # Trace / tracing time api — theory of operation - * - * ## The problem - * - * In a test runtime where many async activities interleave (parallel fixtures, - * timers, promise chains), we want to ask of any scheduled action: "who - * caused this?". Clean causal attribution is useful for: - * - debugging ("which fixture left this timer queued?"), - * - per-owner termination criteria ("queue drained *for my fixture*"), - * - attribution in error messages. - * - * ## The model - * - * A {@link Trace} is an immutable value identifying a causal chain: - * - * Trace = { id; parent?: Trace; label: string; stack?: string; root: Trace } - * - * Roots have no parent. Every non-root trace's `root` points back to the top - * of its chain. Traces are only created by user code, timer wrappers, or the - * scheduler — never implicitly. - * - * ## The runtime state: {@link TraceContext} - * - * A {@link TraceContext} owns the mutable "current frame" slot plus a - * boolean re-entry guard. Production code uses the shared - * {@link TraceContext.instance}; tests can construct fresh instances for - * full isolation. The slot is mutated by two primitives: - * - * - {@link TraceContext.runWithTrace}(t, fn): set current=t, run fn - * synchronously, restore previous frame on exit. Nesting is supported. - * Microtasks enqueued by fn that run *after* fn returns see the - * restored trace. Use only for bounded synchronous regions. - * - * - {@link TraceContext.runAsHandler}(t, fn, resetApi): set current=t, - * run fn, and - * **do not restore synchronously**. The trace is reset on the next - * macrotask via `resetApi.setTimeout(0)`, guarded by a sequence number. - * Any microtask drain that follows `fn` — including `await` continuations - * inside `fn` — inherits `t`. Throws if called while another runAsHandler - * is on the JS call stack. Use for timer callbacks and other async - * entry points (e.g. the fixture's render body). - * - * ## Why this works for attribution - * - * The JS event loop drains microtasks to completion between macrotasks. So - * any microtask enqueued during a macrotask runs with whatever `_currentTrace` - * that macrotask left behind. If that macrotask is a timer callback that - * used `runAsHandler(t, ...)`, every continuation during its drain sees `t`, - * and any timer scheduled during that drain captures `t` via the tracing - * TimeApi wrapper. - * - * Across macrotasks, the chain is preserved by the wrapper: each call to - * `setTimeout`/`setInterval`/`requestAnimationFrame` going through - * {@link createTracingTimeApi} captures the current trace at schedule time - * and re-installs it (as a child) via `runAsHandler` when the callback fires. - * - * ## Why the identity-guarded reset is correct - * - * Two macrotasks A, B firing back-to-back: - * - * [A] runAsHandler(t_A, fnA) -> _current = frame_A; schedule reset_A - * [micro drain] -> await continuations see t_A - * [B] runAsHandler(t_B, fnB) -> _current = frame_B; schedule reset_B - * [micro drain] -> await continuations see t_B - * [reset_A fires] -> sees _current !== frame_A -> no-op - * [reset_B fires] -> _current = frame_A.prev (or its prev) - * - * Each `runAsHandler` mints a fresh {@link Frame}, so the active-frame - * identity check rejects stale resets. This tolerates arbitrary - * interleaving of concurrent handlers (e.g. parallel fixtures) correctly. - * - * ## Caveat: sync re-entry is a bug - * - * `runAsHandler` throws if another `runAsHandler` is already on the JS - * stack. Timer callbacks never run nested on the same stack (the event - * loop runs one at a time), so this throw only fires for misuse (e.g. a - * handler synchronously calling another handler). Nested - * {@link runWithTrace} is always fine — it push/pops synchronously. - */ - -export class Trace { - private static _idCounter = 0; - - public readonly id: number = ++Trace._idCounter; - public readonly root: Trace; - public readonly depth: number; - public readonly createdAt: number = Date.now(); - - constructor( - public readonly parent: Trace | undefined, - public readonly label: string, - public readonly stack: string | undefined = undefined, - ) { - this.root = parent?.root ?? this; - this.depth = (parent?.depth ?? -1) + 1; - } - - child(label: string, stack?: string): Trace { - return new Trace(this, label, stack); - } - - /** - * Renders the causal chain as "#id label ← #id label ← … ← #id label". - */ - describe(): string { - const parts: string[] = []; - for (let t: Trace | undefined = this; t; t = t.parent) { - parts.push(`#${t.id} ${t.label}`); - } - return parts.join(' ← '); - } - - toString(): string { return this.describe(); } -} - -/** Sentinel root for "no known provenance". */ -export const ROOT_TRACE: Trace = new Trace(undefined, ''); - -export function createTraceRoot(label: string, stack?: string): Trace { - return new Trace(undefined, label, stack); -} - -// ============================================================================ -// TraceContext: encapsulated trace state -// ============================================================================ - -export interface TracingTimeApiOptions { - /** Capture a stack trace on every schedule call. Expensive; enable for - * debugging only. */ - readonly captureStacks?: boolean; - /** Observer hook. Called synchronously on schedule and on fire. */ - readonly onEvent?: (event: TracingTimeEvent) => void; -} - -export type TracingTimeEvent = - | { readonly kind: 'schedule'; readonly api: string; readonly trace: Trace; readonly delayMs?: number } - | { readonly kind: 'fire'; readonly api: string; readonly trace: Trace } - | { readonly kind: 'throw'; readonly api: string; readonly trace: Trace; readonly error: unknown }; - /** - * A pushed/popped trace activation. A fresh `Frame` is minted by every - * `runWithTrace` / `runAsHandler` call; identity is what the deferred - * reset in `runAsHandler` uses to detect that it's stale. + * @deprecated The contents of this file have moved to + * `./virtualScheduling/index.js`. This re-export is kept for backwards + * compatibility and will be removed once all callers have migrated. */ -interface Frame { - readonly trace: Trace; - readonly prev: Frame | undefined; -} - -const ROOT_FRAME: Frame = { trace: ROOT_TRACE, prev: undefined }; - -/** - * Holds the mutable "current frame" slot and exposes the trace propagation - * primitives as methods. - * - * Invariants: - * - Reads (`currentTrace()`) are pure. - * - Writes happen only inside `runWithTrace` / `runAsHandler` bodies. - * - Each call mints a fresh {@link Frame} object. A scheduled reset only - * fires if `_current` still points at *its* frame — preventing a stale - * reset from clobbering a newer installation. - * - `_isHandlerRunning` is true iff a `runAsHandler` frame is on the JS - * call stack. It returns to false between macrotasks. - * - * Production callers go through {@link TraceContext.instance}. Tests may - * construct fresh instances to get full isolation. - */ -export class TraceContext { - /** Shared default context. Production callers use this. */ - public static readonly instance = new TraceContext(); - - private _current: Frame = ROOT_FRAME; - private _isHandlerRunning: boolean = false; - private _log: { trace: Trace; message: string }[] = []; - - currentTrace(): Trace { return this._current.trace; } - - /** - * Append `message` to an in-memory log, tagged with the current trace. - * Useful for tests that want to assert the interleaving of work across - * causally distinct roots. Drain via {@link takeLog}. - */ - log(message: string): void { - this._log.push({ trace: this._current.trace, message }); - } - - /** Drain and return all entries logged via {@link log}. */ - takeLog(): readonly { trace: Trace; message: string }[] { - const entries = this._log; - this._log = []; - return entries; - } - - /** - * Install `t` as the current trace for the synchronous duration of `fn`, - * then restore the previous trace. Nestable. Does NOT propagate `t` into - * microtasks enqueued by fn that run *after* fn returns. - * - * Use for bounded synchronous scopes (e.g. iterating a batch of tagged - * callbacks within a single tick). - */ - runWithTrace(t: Trace, fn: () => T): T { - const prev = this._current; - const next = { trace: t, prev }; - this._current = next; - try { - return fn(); - } finally { - if (this._current !== next) { - // eslint-disable-next-line no-unsafe-finally - throw new BugIndicatingError( - `traceableTimeApi: runWithTrace detected unexpected mutation. ` + - `current=${this._current.trace.describe()}, expected=${t.describe()}` - ); - } - this._current = prev; - } - } - - /** - * Install `t` as the current trace, run `fn`, and keep `t` installed - * through the microtask drain that follows. Restore the previous trace - * via an identity-guarded `resetApi.setTimeout(0)` so that awaited - * continuations within `fn` observe `t`. - * - * Throws if called while another `runAsHandler` frame is on the JS call - * stack (synchronous re-entry is a bug). - */ - runAsHandler(t: Trace, fn: () => T, resetApi: TimeApi): T { - if (this._isHandlerRunning) { - throw new Error( - `traceableTimeApi: re-entrant runAsHandler detected. ` + - `current=${this._current.trace.describe()}, incoming=${t.describe()}` - ); - } - const prev = this._current; - const next: Frame = { trace: t, prev }; - this._current = next; - this._isHandlerRunning = true; - try { - return fn(); - } finally { - this._isHandlerRunning = false; - // Do NOT restore synchronously: microtasks enqueued by fn (including - // awaited continuations) must observe `t`. Schedule an - // identity-guarded reset on the next macrotask via the raw - // real-time API. - // - // `_current !== next` is the normal case when handlers - // interleave: another `runAsHandler` ran between us scheduling - // this reset and it firing, so it pushed its own frame and - // queued its own reset that will do the restoring. Skipping - // here is correct — see the class doc "identity-guarded reset". - resetApi.setTimeout(() => { - if (this._current === next) { - this._current = prev; - } - }, 0); - } - } - - /** - * Wrap `wrapped` so that every scheduled callback is tagged with the - * current trace at schedule time, and re-installed via - * {@link runAsHandler} when it fires. `resetApi` is used to schedule - * trace resets and must be a real-time API (pointing it at a virtual - * API would prevent resets from ever firing). - * - * Re-entrancy with a virtual-time scheduler: do NOT stack this wrapper - * over a virtual-time `TimeApi`. The scheduler already installs traces - * via `runAsHandler` when it runs each task, and a second - * `runAsHandler` from this wrapper would trip the sync re-entry guard. - */ - createTracingTimeApi( - wrapped: TimeApi, - resetApi: TimeApi, - options: TracingTimeApiOptions = {}, - ): TimeApi { - const captureStacks = options.captureStacks ?? false; - const onEvent = options.onEvent; - - const capture = (label: string, delayMs?: number): Trace => { - const stack = captureStacks ? new Error().stack : undefined; - const t = this._current.trace.child(label, stack); - onEvent?.({ kind: 'schedule', api: label, trace: t, delayMs }); - return t; - }; - - const invoke = (trace: Trace, api: string, body: () => void): void => { - onEvent?.({ kind: 'fire', api, trace }); - try { - this.runAsHandler(trace, body, resetApi); - } catch (e) { - onEvent?.({ kind: 'throw', api, trace, error: e }); - throw e; - } - }; - - const api: TimeApi = { - Date: wrapped.Date, - setTimeout: (handler: () => void, ms?: number) => { - const t = capture(`setTimeout(${ms ?? 0}ms)`, ms); - return wrapped.setTimeout(() => invoke(t, 'setTimeout', handler), ms); - }, - clearTimeout: (id: unknown) => wrapped.clearTimeout(id), - setInterval: (handler: () => void, interval: number) => { - const base = capture(`setInterval(${interval}ms)`, interval); - let tickIdx = 0; - return wrapped.setInterval(() => { - const tickTrace = base.child(`tick #${++tickIdx}`); - invoke(tickTrace, 'setInterval', handler); - }, interval); - }, - clearInterval: (id: unknown) => wrapped.clearInterval(id), - }; - - if (wrapped.setImmediate) { - api.setImmediate = (handler: () => void) => { - const t = capture('setImmediate'); - return wrapped.setImmediate!(() => invoke(t, 'setImmediate', handler)); - }; - api.clearImmediate = (id: unknown) => wrapped.clearImmediate?.(id); - } - - if (wrapped.requestAnimationFrame) { - api.requestAnimationFrame = (cb: (time: number) => void) => { - const t = capture('requestAnimationFrame'); - return wrapped.requestAnimationFrame!(time => invoke(t, 'requestAnimationFrame', () => cb(time))); - }; - api.cancelAnimationFrame = (id: number) => wrapped.cancelAnimationFrame?.(id); - } - api.originalFunctions = wrapped.originalFunctions ?? wrapped; - return api; - } +export { + createTraceRoot, + ROOT_TRACE, + Trace, + TraceContext, +} from './virtualScheduling/index.js'; - /** Reset state. Only intended for tests. */ - _resetForTesting(): void { - this._current = ROOT_FRAME; - this._isHandlerRunning = false; - this._log = []; - } -} +export type { RunAsHandlerOptions } from './virtualScheduling/index.js'; diff --git a/src/vs/base/test/common/virtualScheduling/embedding.ts b/src/vs/base/test/common/virtualScheduling/embedding.ts new file mode 100644 index 00000000000000..e7f7b7d1350085 --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/embedding.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { setTimeout0, setTimeout0IsFaster } from '../../../common/platform.js'; +import { TimeApi } from './timeApi.js'; +import { VirtualEvent } from './virtualClock.js'; + +/** + * # The processor/host embedding + * + * An {@link Embedding} is the contract between the processor's pure state + * machine and the host event loop. It is invoked once per virtual step that + * produced progress, and decides *how* the processor reaches the host before + * the next step. + * + * ## Contract + * + * On each invocation the embedding MUST do exactly one of: + * + * 1. Return `'continueSync'` **without** calling `then`. The processor will + * loop in place on the same host stack frame. + * + * 2. Schedule `then` on a host primitive (microtask, macrotask, paint frame) + * and return `'cbScheduled'`. The processor will return and wait for the + * callback to re-enter the trampoline. + * + * The embedding MUST NOT call `then` synchronously and also return + * `'cbScheduled'` (that would re-enter the trampoline before this call + * completed). Likewise, returning `'continueSync'` while having scheduled + * `then` async would cause `then` to fire after the trampoline already + * looped — also a bug. + * + * ## Why a callback contract instead of async/await + * + * Every `await` is an implicit microtask hop. For code whose job is to + * decide host hops, that's the wrong abstraction: the reader has to mentally + * compile the `await` to a boundary. With this contract, every host hop is + * a named call to a single primitive (`api.setTimeout`, `setTimeout0`, + * `api.requestAnimationFrame`, …) at exactly one site in this file. + */ +export type Embedding = ( + nextEvent: VirtualEvent, + then: () => void, +) => 'continueSync' | 'cbScheduled'; + +/** + * Tasks never schedule via promise chains. The processor runs virtual events + * back-to-back on a single host stack frame — fastest possible, but starves + * the host event loop for the duration of the run. + * + * Use only for tests where no `await` / `.then` chains are involved between + * scheduling and execution of virtual events. + */ +export const syncEmbedding: Embedding = () => 'continueSync'; + +/** + * Tasks may schedule via `await` / `.then`. Between virtual events, yield to + * the host so the *microtask closure* — the current microtask plus every + * microtask it transitively enqueues — drains before the next event runs. + * + * This is the embedding to use for almost all integration-style tests. + */ +export function drainMicrotasksEmbedding(realApi: TimeApi): Embedding { + return (next, then) => { + if (next.preferRealAnimationFrame && realApi.requestAnimationFrame) { + realApi.requestAnimationFrame(() => then()); + } else { + nextMacrotask(realApi, then); + } + return 'cbScheduled'; + }; +} + +/** + * Schedule `cb` after the closure of the current microtask queue: `cb` + * fires only after the current microtask AND every microtask it + * (recursively, transitively) enqueues has settled. + * + * Per the HTML spec, a macrotask runs only when the microtask queue is + * empty, so any macrotask primitive achieves this. We pick the fastest + * one available on the host. + */ +export function nextMacrotask(api: TimeApi, cb: () => void): void { + if (setTimeout0IsFaster) { setTimeout0(cb); return; } + if (api.setImmediate) { api.setImmediate(cb); return; } + api.setTimeout(cb, 0); +} diff --git a/src/vs/base/test/common/virtualScheduling/globalTimeApi.ts b/src/vs/base/test/common/virtualScheduling/globalTimeApi.ts new file mode 100644 index 00000000000000..17bbb04607881e --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/globalTimeApi.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../common/lifecycle.js'; +import { captureGlobalTimeApi, realTimeApi, TimeApi } from './timeApi.js'; + +/** Cast through `unknown` so we don't widen our typed `TimeApi` shapes to `any`. */ +type AsGlobal = (typeof globalThis)[K]; + +/** + * Ensure `fn` carries an `originalFn` back-door pointing at the real + * (non-virtual) `setTimeout`. We prefer the existing tag on `fn`, then a tag + * inherited from `previousFn` (which may itself be a wrapper that already + * carried the back-door), and finally fall back to `realTimeApi.setTimeout` + * — which has its own `originalFn` set at module load. + */ +function ensureSetTimeoutOriginalFn(fn: TimeApi['setTimeout'], previousFn: TimeApi['setTimeout']): TimeApi['setTimeout'] { + const tagged = fn as TimeApi['setTimeout'] & { originalFn?: TimeApi['setTimeout'] }; + if (!tagged.originalFn) { + const previousTagged = previousFn as TimeApi['setTimeout'] & { originalFn?: TimeApi['setTimeout'] }; + const realTagged = realTimeApi.setTimeout as TimeApi['setTimeout'] & { originalFn?: TimeApi['setTimeout'] }; + tagged.originalFn = previousTagged.originalFn ?? realTagged.originalFn ?? realTimeApi.setTimeout; + } + return tagged; +} + +/** + * Replace the global time APIs (`setTimeout`, `setInterval`, …, `Date`, + * optionally `requestAnimationFrame`) with the ones from `api`. Returns a + * disposable that restores the previous globals. + * + * The previous globals are captured *at install time*, so nested installs + * compose correctly (the disposable restores to whatever was current when + * this call was made, not to the original real values). + * + * `setTimeout.originalFn` is preserved on the installed function so callers + * like the component-explorer host can escape virtual time when polling. + * If `api.setTimeout` does not already carry `originalFn`, it is copied from + * the previous global (or defaulted to the real `setTimeout`) so wrapping + * APIs such as a logging wrapper don't drop the back-door. + */ +export function pushGlobalTimeApi(api: TimeApi): IDisposable { + const previous = captureGlobalTimeApi(); + + globalThis.setTimeout = ensureSetTimeoutOriginalFn(api.setTimeout, previous.setTimeout) as unknown as AsGlobal<'setTimeout'>; + globalThis.clearTimeout = api.clearTimeout as unknown as AsGlobal<'clearTimeout'>; + globalThis.setInterval = api.setInterval as unknown as AsGlobal<'setInterval'>; + globalThis.clearInterval = api.clearInterval as unknown as AsGlobal<'clearInterval'>; + globalThis.Date = api.Date; + + if (api.requestAnimationFrame) { + globalThis.requestAnimationFrame = api.requestAnimationFrame as unknown as AsGlobal<'requestAnimationFrame'>; + } + if (api.cancelAnimationFrame) { + globalThis.cancelAnimationFrame = api.cancelAnimationFrame as unknown as AsGlobal<'cancelAnimationFrame'>; + } + + return { + dispose: () => { + globalThis.setTimeout = ensureSetTimeoutOriginalFn(previous.setTimeout, previous.setTimeout) as unknown as AsGlobal<'setTimeout'>; + globalThis.clearTimeout = previous.clearTimeout as unknown as AsGlobal<'clearTimeout'>; + globalThis.setInterval = previous.setInterval as unknown as AsGlobal<'setInterval'>; + globalThis.clearInterval = previous.clearInterval as unknown as AsGlobal<'clearInterval'>; + globalThis.Date = previous.Date; + if (previous.requestAnimationFrame) { + globalThis.requestAnimationFrame = previous.requestAnimationFrame as unknown as AsGlobal<'requestAnimationFrame'>; + } + if (previous.cancelAnimationFrame) { + globalThis.cancelAnimationFrame = previous.cancelAnimationFrame as unknown as AsGlobal<'cancelAnimationFrame'>; + } + }, + }; +} + +// One-shot tag on the *real* setTimeout: lets callers (e.g. the +// component-explorer host's polling loop) escape virtual time even after +// pushGlobalTimeApi has installed a virtual version on top. The `originalFn` +// property is not on the `setTimeout` signature by design — it's a back-door +// convention shared with the polling code. +(realTimeApi.setTimeout as unknown as { originalFn: TimeApi['setTimeout'] }).originalFn = realTimeApi.setTimeout; diff --git a/src/vs/base/test/common/virtualScheduling/index.ts b/src/vs/base/test/common/virtualScheduling/index.ts new file mode 100644 index 00000000000000..6fc1a232f58db5 --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/index.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Greenfield virtual scheduling primitives. +// +// This folder is the new home for virtual-time scheduling. It supersedes +// `timeTravelScheduler.ts` and `traceableTimeApi.ts`, both of which are +// retained as @deprecated re-export shims. + +export type { TimeApi } from './timeApi.js'; +export { captureGlobalTimeApi, realTimeApi } from './timeApi.js'; + +export type { EventSource, VirtualEvent, VirtualTime } from './virtualClock.js'; +export { VirtualClock } from './virtualClock.js'; + +export type { RunAsHandlerOptions } from './trace.js'; +export { ROOT_TRACE, Trace, TraceContext, createTraceRoot } from './trace.js'; + +export type { Embedding } from './embedding.js'; +export { drainMicrotasksEmbedding, nextMacrotask, syncEmbedding } from './embedding.js'; + +export type { RunOptions, TerminationPolicy, VirtualTimeProcessorOptions } from './processor.js'; +export { VirtualTimeProcessor, untilIdle, untilTime, untilToken } from './processor.js'; + +export { pushGlobalTimeApi } from './globalTimeApi.js'; +export type { CreateVirtualTimeApiOptions } from './virtualTimeApi.js'; +export { createVirtualTimeApi } from './virtualTimeApi.js'; +export { createLoggingTimeApi } from './loggingTimeApi.js'; +export type { RunWithFakedTimersOptions } from './runWithFakedTimers.js'; +export { runWithFakedTimers } from './runWithFakedTimers.js'; diff --git a/src/vs/base/test/common/virtualScheduling/loggingTimeApi.ts b/src/vs/base/test/common/virtualScheduling/loggingTimeApi.ts new file mode 100644 index 00000000000000..e0840cb7ee0417 --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/loggingTimeApi.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TimeApi } from './timeApi.js'; + +/** + * Wrap `underlying` so that every call to `setTimeout`, `setInterval`, + * `setImmediate` or `requestAnimationFrame` invokes `onCall` first. + * + * Useful for diagnostics — e.g. logging timer registrations made outside of + * virtual time, to find leaks of real-time scheduling into a fixture. + */ +export function createLoggingTimeApi( + underlying: TimeApi, + onCall: (name: string, stack: string | undefined, handler?: () => void) => void, +): TimeApi { + return { + setTimeout(handler, timeout) { + onCall('setTimeout', new Error().stack, handler); + return underlying.setTimeout(handler, timeout); + }, + clearTimeout(id) { return underlying.clearTimeout(id); }, + setInterval(handler, interval) { + onCall('setInterval', new Error().stack, handler); + return underlying.setInterval(handler, interval); + }, + clearInterval(id) { return underlying.clearInterval(id); }, + setImmediate: underlying.setImmediate ? handler => { + onCall('setImmediate', new Error().stack, handler); + return underlying.setImmediate!(handler); + } : undefined, + clearImmediate: underlying.clearImmediate, + requestAnimationFrame: underlying.requestAnimationFrame ? cb => { + onCall('requestAnimationFrame', new Error().stack, cb as () => void); + return underlying.requestAnimationFrame!(cb); + } : undefined, + cancelAnimationFrame: underlying.cancelAnimationFrame, + Date: underlying.Date, + }; +} diff --git a/src/vs/base/test/common/virtualScheduling/processor.ts b/src/vs/base/test/common/virtualScheduling/processor.ts new file mode 100644 index 00000000000000..5d44f7233f830c --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/processor.ts @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../common/cancellation.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js'; +import { Embedding, nextMacrotask } from './embedding.js'; +import { TimeApi } from './timeApi.js'; +import { ROOT_TRACE, TraceContext } from './trace.js'; +import { EventSource, VirtualClock, VirtualEvent, VirtualTime } from './virtualClock.js'; + +// ============================================================================ +// Termination policy +// ============================================================================ + +/** + * When a {@link Run} should terminate. + * + * Greenfield design choice: termination is *always* explicit. There is no + * "bare run()" that terminates on first empty queue, because that creates a + * race with the caller's microtask chain (the run can resolve before the + * caller's `.then` has had a chance to schedule). + */ +export type TerminationPolicy = + /** Resolve as soon as the virtual queue is empty. */ + | { readonly kind: 'idle' } + /** Resolve when the token is cancelled AND the queue is empty. */ + | { readonly kind: 'token'; readonly token: CancellationToken } + /** Resolve when virtual time has reached `time` and all events scheduled + * at or before `time` have been processed. A sentinel event at `time` + * is scheduled by the processor so virtual time always reaches it. */ + | { readonly kind: 'time'; readonly time: VirtualTime }; + +export const untilIdle: TerminationPolicy = { kind: 'idle' }; +export function untilToken(token: CancellationToken): TerminationPolicy { return { kind: 'token', token }; } +export function untilTime(time: VirtualTime): TerminationPolicy { return { kind: 'time', time }; } + +export interface RunOptions { + readonly until: TerminationPolicy; + /** Maximum number of virtual events this run will execute. Default: 100. */ + readonly maxEvents?: number; + /** Maximum causal-trace depth this run will tolerate. Useful for catching + * runaway self-rescheduling timers. */ + readonly maxTraceDepth?: number; +} + +// ============================================================================ +// Run — internal state for a single processor.run() invocation +// ============================================================================ + +type RunStatus = 'continue' | 'done' | { readonly error: Error }; + +class Run { + private static _idCounter = 0; + public readonly id = ++Run._idCounter; + + public readonly promise: Promise; + private _resolve!: () => void; + private _reject!: (e: Error) => void; + private _settled = false; + public get settled(): boolean { return this._settled; } + + constructor( + public readonly options: RunOptions, + public readonly executedAtStart: number, + public readonly maxEvents: number, + ) { + this.promise = new Promise((res, rej) => { this._resolve = res; this._reject = rej; }); + } + + settle(error?: Error): void { + if (this._settled) { return; } + this._settled = true; + if (error) { this._reject(error); } else { this._resolve(); } + } + + evaluate(clock: VirtualClock, executedTotal: number, makeOverflow: () => Error): RunStatus { + const local = executedTotal - this.executedAtStart; + if (local >= this.maxEvents && clock.hasEvents) { + return { error: makeOverflow() }; + } + + const u = this.options.until; + switch (u.kind) { + case 'idle': + return clock.hasEvents ? 'continue' : 'done'; + case 'token': + return u.token.isCancellationRequested && !clock.hasEvents ? 'done' : 'continue'; + case 'time': { + // Done iff every remaining event is strictly past the deadline. + // The sentinel guarantees the queue is non-empty until at + // least the deadline is reached, so we never resolve "early" + // just because nothing has been scheduled yet. + const next = clock.peekNext(); + return next === undefined || next.time > u.time ? 'done' : 'continue'; + } + } + } +} + +// ============================================================================ +// Step outcome — what the pure state machine tells the trampoline +// ============================================================================ + +type StepOutcome = + /** Either a virtual event was executed, or a run was rejected for a + * bookkeeping reason (depth/event overflow). The trampoline should let + * the embedding decide how to reach the next step. */ + | 'progress' + /** No actionable event under any active deadline. The trampoline should + * park until something wakes the processor. */ + | 'park' + /** No active runs. The trampoline should stop driving. */ + | 'quiesce'; + +// ============================================================================ +// VirtualTimeProcessor +// ============================================================================ + +export interface VirtualTimeProcessorOptions { + readonly defaultMaxEvents?: number; +} + +/** + * # VirtualTimeProcessor + * + * Drives a {@link VirtualClock} from the host event loop. This is the + * **embedding** of a small virtual event loop into the host event loop. + * + * ## Responsibilities, separated + * + * - {@link _step} is a *pure* state-machine advance. It reads the clock, + * decides what to do, optionally executes one virtual event, and returns + * a {@link StepOutcome}. It never touches host time. + * + * - {@link _drive} is the *trampoline*. It calls `_step` and lets the + * {@link Embedding} decide whether to loop in place (`'continueSync'`) + * or schedule the next iteration on the host (`'cbScheduled'`). It is + * the only code that touches host time. + * + * - {@link Run} carries the user's termination predicate. Runs are pure + * over `_step`'s observations; they never schedule. + * + * ## Invariants + * + * 1. **Single driver.** At any moment at most one `_drive` invocation is + * active per processor (the `_inDrive` guard). + * + * 2. **Step is pure w.r.t. host time.** `_step` only reads the clock, + * mutates the run set via settling, and synchronously runs at most one + * virtual event. It never calls into a host time API. + * + * 3. **Embedding chooses the host primitive.** Whether the next step runs + * inline, after a microtask drain, or on a paint frame is entirely the + * embedding's decision — *per event*. + * + * 4. **Park is breakable.** While parked, the processor wakes on + * {@link VirtualClock.onEventScheduled}, on a token cancellation, and + * on a new run being added. + * + * 5. **Disposal is terminal.** After dispose, all runs are rejected and + * `_step`/`_drive` short-circuit to `'quiesce'`. + * + * ## On the trace-reset sink + * + * The trace context's deferred reset (see {@link TraceContext.runAsHandler}) + * needs a "fire after the microtask closure" primitive. The processor passes + * its *own* {@link nextMacrotask} as that sink, so the reset goes through + * the same primitive the embedding uses for its own host hops. This removes + * any race between the processor's hops and the trace-reset timer. + */ +export class VirtualTimeProcessor extends Disposable { + + private readonly _runs = new Map(); + private readonly _history: VirtualEvent[] = []; + private _executedTotal = 0; + private _disposed = false; + + private _inDrive = false; + private _parkCleanup: IDisposable | undefined; + + private readonly _defaultMaxEvents: number; + + public get history(): readonly VirtualEvent[] { return this._history; } + public get executedTotal(): number { return this._executedTotal; } + + constructor( + private readonly _clock: VirtualClock, + private readonly _embedding: Embedding, + private readonly _realApi: TimeApi, + opts: VirtualTimeProcessorOptions = {}, + ) { + super(); + this._defaultMaxEvents = opts.defaultMaxEvents ?? 100; + this._register({ dispose: () => this._onDispose() }); + } + + // ---- Public API ----------------------------------------------------- + + /** Start a run with the given termination policy. */ + run(options: RunOptions): Promise { + const run = new Run(options, this._executedTotal, options.maxEvents ?? this._defaultMaxEvents); + const cleanup = new DisposableStore(); + + // Wake the loop on token cancellation so the run can re-evaluate. + if (options.until.kind === 'token') { + cleanup.add(options.until.token.onCancellationRequested(() => this._wake())); + } + + // For time-based termination, schedule a sentinel event at the + // deadline. This guarantees virtual time reaches the deadline even if + // the user never schedules anything else, and that the run does not + // resolve early just because the queue happens to be empty *now*. + if (options.until.kind === 'time' && options.until.time > this._clock.now) { + const source: EventSource = { toString: () => `` }; + cleanup.add(this._clock.schedule({ + time: options.until.time, + source, + run: () => { /* sentinel: no-op */ }, + })); + } + + this._runs.set(run, cleanup); + this._wake(); + return run.promise; + } + + // ---- The pure step -------------------------------------------------- + + private _step(): StepOutcome { + if (this._disposed) { return 'quiesce'; } + + this._settleFinishedRuns(); + if (this._runs.size === 0) { return 'quiesce'; } + + const next = this._clock.peekNext(); + if (next === undefined) { return 'park'; } + + // Per-run trace-depth check: reject any run whose limit this event + // would exceed, before executing. + const traceDepth = next.trace?.depth ?? 0; + let depthOverflow = false; + for (const run of [...this._runs.keys()]) { + const limit = run.options.maxTraceDepth; + if (limit !== undefined && traceDepth > limit) { + this._settleRun(run, this._buildDepthOverflow(run, traceDepth)); + depthOverflow = true; + } + } + if (depthOverflow) { return 'progress'; } + + this._executeOne(next); + return 'progress'; + } + + private _executeOne(event: VirtualEvent): void { + try { + TraceContext.instance.runAsHandler( + event.trace ?? ROOT_TRACE, + () => { + const e = this._clock.runNext(); + if (e) { + this._history.push(e); + this._executedTotal++; + } + }, + { + // Route the trace-reset through the same host primitive + // the embedding uses, so there is no race between this + // timer and the embedding's next hop. + afterMicrotaskClosure: cb => nextMacrotask(this._realApi, cb), + }, + ); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + // We can't tell which run "owned" the throwing event. Reject all + // active runs so the failure is observed exactly once per caller. + for (const run of [...this._runs.keys()]) { this._settleRun(run, err); } + } + } + + // ---- The trampoline ------------------------------------------------- + + private readonly _drive = (): void => { + if (this._inDrive) { return; } + this._inDrive = true; + try { + while (true) { + const outcome = this._step(); + if (outcome === 'quiesce') { return; } + if (outcome === 'park') { this._park(); return; } + + // 'progress': read the next event so the embedding can pick a + // per-event primitive. If there is none, loop and let the next + // `_step` decide between 'park' and 'quiesce'. + const next = this._clock.peekNext(); + if (next === undefined) { continue; } + + const choice = this._embedding(next, this._drive); + if (choice === 'cbScheduled') { return; } + // 'continueSync': loop in place. + } + } finally { + this._inDrive = false; + } + }; + + // ---- Park & wake ---------------------------------------------------- + + private _park(): void { + this._unpark(); + const store = new DisposableStore(); + store.add(this._clock.onEventScheduled(() => this._wake())); + this._parkCleanup = store; + } + + private _unpark(): void { + this._parkCleanup?.dispose(); + this._parkCleanup = undefined; + } + + private _wake(): void { + if (this._disposed) { return; } + this._unpark(); + // Re-enter the trampoline on a host macrotask, NOT a microtask. This: + // - coalesces multiple wake() calls in the same tick, + // - keeps the driver off the caller's stack frame, and + // - lets the entire pending microtask closure (including microtasks + // enqueued AFTER this `_wake` call within the same outer microtask + // -- e.g. `queueMicrotask(...)` calls inside an `AsyncIterable` + // constructor that runs after `clock.schedule` triggered the wake) + // drain before the next `_step`. A microtask hop here would queue + // the driver in FIFO order with those subsequent microtasks, so the + // driver could run a virtual event before the consumer-side promise + // chain that depends on it has settled. + nextMacrotask(this._realApi, this._drive); + } + + // ---- Run lifecycle -------------------------------------------------- + + private _settleFinishedRuns(): void { + for (const run of [...this._runs.keys()]) { + if (run.settled) { continue; } + const status = run.evaluate(this._clock, this._executedTotal, () => this._buildOverflow(run)); + if (status === 'done') { + this._settleRun(run); + } else if (typeof status === 'object') { + this._settleRun(run, status.error); + } + } + } + + private _settleRun(run: Run, error?: Error): void { + const cleanup = this._runs.get(run); + if (!cleanup) { return; } + this._runs.delete(run); + cleanup.dispose(); + run.settle(error); + } + + private _buildOverflow(run: Run): Error { + const local = this._executedTotal - run.executedAtStart; + return new Error( + `[VirtualTimeProcessor] Run #${run.id} exceeded maxEvents (${run.maxEvents}) — ` + + `executed ${local} virtual event(s) and the queue is still not empty.` + ); + } + + private _buildDepthOverflow(run: Run, depth: number): Error { + return new Error( + `[VirtualTimeProcessor] Run #${run.id} exceeded maxTraceDepth (${run.options.maxTraceDepth}) — ` + + `next event has trace depth ${depth}. ` + + `This usually indicates a runaway self-rescheduling timer.` + ); + } + + private _onDispose(): void { + this._disposed = true; + this._unpark(); + const err = new Error('VirtualTimeProcessor disposed'); + for (const run of [...this._runs.keys()]) { this._settleRun(run, err); } + } +} diff --git a/src/vs/base/test/common/virtualScheduling/runWithFakedTimers.ts b/src/vs/base/test/common/virtualScheduling/runWithFakedTimers.ts new file mode 100644 index 00000000000000..4d4e2be9409a2e --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/runWithFakedTimers.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../common/cancellation.js'; +import { drainMicrotasksEmbedding } from './embedding.js'; +import { pushGlobalTimeApi } from './globalTimeApi.js'; +import { realTimeApi } from './timeApi.js'; +import { untilToken, VirtualTimeProcessor } from './processor.js'; +import { VirtualClock } from './virtualClock.js'; +import { createVirtualTimeApi } from './virtualTimeApi.js'; + +export interface RunWithFakedTimersOptions { + readonly startTime?: number; + /** Default `true`. Set `false` to bypass virtual time entirely (for + * cases where the same test is parameterised over real/virtual time). */ + readonly useFakeTimers?: boolean; + /** No effect in the new processor; accepted for legacy compatibility. + * The drain-microtasks embedding picks the fastest available macrotask + * primitive automatically. */ + readonly useSetImmediate?: boolean; + /** Maximum number of virtual events the run is allowed to execute + * before being rejected. Default 100. */ + readonly maxTaskCount?: number; +} + +/** + * Run `fn` with a virtual clock installed as the global time API. + * + * After `fn` resolves, the virtual queue is drained (so any timers `fn` + * scheduled and `await`ed for, transitively, complete deterministically). + * If `fn` throws, the queue is *not* drained — the original error is + * re-thrown immediately. + */ +export async function runWithFakedTimers( + options: RunWithFakedTimersOptions, + fn: () => Promise, +): Promise { + const useFakeTimers = options.useFakeTimers !== false; + if (!useFakeTimers) { return fn(); } + + const clock = new VirtualClock(options.startTime ?? 0); + const virtualApi = createVirtualTimeApi(clock); + const restoreGlobals = pushGlobalTimeApi(virtualApi); + + const processor = new VirtualTimeProcessor( + clock, + drainMicrotasksEmbedding(realTimeApi), + realTimeApi, + { defaultMaxEvents: options.maxTaskCount ?? 100 }, + ); + + const cts = new CancellationTokenSource(); + const runPromise = processor.run({ until: untilToken(cts.token) }); + + let didThrow = true; + let result: T; + try { + result = await fn(); + didThrow = false; + } finally { + // Stop intercepting real-time scheduling before draining: any tasks + // scheduled during the drain itself must not land back in the + // virtual queue. + restoreGlobals.dispose(); + cts.cancel(); + + try { + if (!didThrow) { + await runPromise; + } else { + // Avoid an unhandled rejection in case disposal rejects the + // run. + runPromise.catch(() => { /* swallowed: fn() already failed */ }); + } + } finally { + cts.dispose(); + processor.dispose(); + } + } + + return result; +} diff --git a/src/vs/base/test/common/virtualScheduling/timeApi.ts b/src/vs/base/test/common/virtualScheduling/timeApi.ts new file mode 100644 index 00000000000000..a5e2fd15e2e6a7 --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/timeApi.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface TimeoutId { readonly _timeoutIdBrand: void } +export interface IntervalId { readonly _intervalIdBrand: void } +export interface ImmediateId { readonly _immediateIdBrand: void } +export type AnimationFrameId = number & { readonly _animationFrameIdBrand: void }; + +/** + * The subset of host time APIs the processor and embeddings need. + * + * Used both for the real host API (captured via {@link captureGlobalTimeApi}) + * and for the virtual replacement that runs through a {@link VirtualClock}. + * + * Keeping this as a plain interface means the processor never reaches into + * `globalThis` directly: the boundary between "real time" and "virtual time" + * is exactly which `TimeApi` instance is in use. + */ +export interface TimeApi { + setTimeout(handler: () => void, timeout?: number): TimeoutId; + clearTimeout(id: TimeoutId): void; + setInterval(handler: () => void, interval: number): IntervalId; + clearInterval(id: IntervalId): void; + setImmediate?: ((handler: () => void) => ImmediateId); + clearImmediate?: ((id: ImmediateId) => void); + requestAnimationFrame?: ((cb: (time: number) => void) => AnimationFrameId); + cancelAnimationFrame?: ((id: AnimationFrameId) => void); + Date: DateConstructor; +} + +export function captureGlobalTimeApi(): TimeApi { + return { + setTimeout: globalThis.setTimeout.bind(globalThis) as unknown as TimeApi['setTimeout'], + clearTimeout: globalThis.clearTimeout.bind(globalThis) as unknown as TimeApi['clearTimeout'], + setInterval: globalThis.setInterval.bind(globalThis) as unknown as TimeApi['setInterval'], + clearInterval: globalThis.clearInterval.bind(globalThis) as unknown as TimeApi['clearInterval'], + setImmediate: globalThis.setImmediate?.bind(globalThis) as unknown as TimeApi['setImmediate'], + clearImmediate: globalThis.clearImmediate?.bind(globalThis) as unknown as TimeApi['clearImmediate'], + requestAnimationFrame: globalThis.requestAnimationFrame?.bind(globalThis) as unknown as TimeApi['requestAnimationFrame'], + cancelAnimationFrame: globalThis.cancelAnimationFrame?.bind(globalThis) as unknown as TimeApi['cancelAnimationFrame'], + Date: globalThis.Date, + }; +} + +/** A snapshot of the real host time API at module-load time. */ +export const realTimeApi: TimeApi = captureGlobalTimeApi(); diff --git a/src/vs/base/test/common/virtualScheduling/trace.ts b/src/vs/base/test/common/virtualScheduling/trace.ts new file mode 100644 index 00000000000000..c0ce2a7d1cf9fa --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/trace.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BugIndicatingError } from '../../../common/errors.js'; + +/** + * # Trace — causal-chain attribution for scheduled work + * + * A {@link Trace} is an immutable value identifying a causal chain. Every + * non-root trace carries a `parent`; the head of the chain has no parent. + * Use {@link child} to extend a chain when scheduling follow-up work. + * + * Traces are used to answer "who caused this?" for any virtual event: + * useful for debugging, for per-owner termination, and for attribution in + * error messages. + */ +export class Trace { + private static _idCounter = 0; + public readonly id: number = ++Trace._idCounter; + public readonly root: Trace; + public readonly depth: number; + + constructor( + public readonly parent: Trace | undefined, + public readonly label: string, + public readonly stack: string | undefined = undefined, + ) { + this.root = parent?.root ?? this; + this.depth = (parent?.depth ?? -1) + 1; + } + + child(label: string, stack?: string): Trace { + return new Trace(this, label, stack); + } + + /** "#id label ← #id label ← … ← #id label" */ + describe(): string { + const parts: string[] = []; + for (let t: Trace | undefined = this; t; t = t.parent) { + parts.push(`#${t.id} ${t.label}`); + } + return parts.join(' ← '); + } + + toString(): string { return this.describe(); } +} + +/** Sentinel for "no known causal predecessor". */ +export const ROOT_TRACE: Trace = new Trace(undefined, ''); + +export function createTraceRoot(label: string, stack?: string): Trace { + return new Trace(undefined, label, stack); +} + +interface Frame { + readonly trace: Trace; + readonly prev: Frame | undefined; +} + +const ROOT_FRAME: Frame = { trace: ROOT_TRACE, prev: undefined }; + +/** + * Options for {@link TraceContext.runAsHandler}. + * + * # Why this is a per-call option + * + * `runAsHandler` cannot restore the previous trace synchronously: microtasks + * enqueued by `fn` (including awaited continuations) must observe the new + * trace. So the reset is deferred — but it must fire after the *closure* of + * the microtask queue (the current microtask plus every microtask it + * recursively enqueues), not just one drain. + * + * Per spec, the host doesn't run a macrotask until the microtask queue is + * empty, so any macrotask primitive (`setTimeout(0)`, `setImmediate`, the + * `setTimeout0` shim) achieves this. Letting the *caller* supply the sink + * means: + * + * - the {@link VirtualTimeProcessor} can route the reset through the same + * primitive its embedding uses for its own host hops, eliminating any + * race between the processor's hops and the trace-reset timer; + * + * - production code without a processor can still use a real + * `setTimeout(0)`-based sink and get the same semantics; + * + * - tests can install a deterministic sink (e.g. a hand-driven queue) for + * fully synchronous assertions. + */ +export interface RunAsHandlerOptions { + /** + * Sink for the deferred trace-reset. + * + * Must invoke `reset` after the microtask closure that follows the + * `runAsHandler` call returns — i.e. on the next host macrotask. + */ + readonly afterMicrotaskClosure: (reset: () => void) => void; +} + +/** + * Holds the mutable "current trace frame" slot. Construct fresh instances + * for test isolation, or use {@link TraceContext.instance} for shared state. + */ +export class TraceContext { + public static readonly instance = new TraceContext(); + + private _current: Frame = ROOT_FRAME; + private _isHandlerRunning = false; + + currentTrace(): Trace { return this._current.trace; } + + /** + * Install `t` as current for the synchronous duration of `fn`, then + * restore. Nestable. Microtasks enqueued by fn that run after fn returns + * see the *restored* trace — use {@link runAsHandler} when continuation + * inheritance is wanted. + */ + runWithTrace(t: Trace, fn: () => T): T { + const prev = this._current; + const next: Frame = { trace: t, prev }; + this._current = next; + try { + return fn(); + } finally { + if (this._current !== next) { + // eslint-disable-next-line no-unsafe-finally + throw new BugIndicatingError( + `runWithTrace: unexpected mutation of current frame.` + ); + } + this._current = prev; + } + } + + /** + * Install `t` as current and run `fn`. The trace stays current through + * the microtask closure that follows `fn`, so awaited continuations + * inside fn observe `t`. The reset is dispatched via + * `opts.afterMicrotaskClosure`. + * + * Throws on synchronous re-entry: timer callbacks never nest on the + * same JS stack frame, so this only fires for misuse. + */ + runAsHandler(t: Trace, fn: () => T, opts: RunAsHandlerOptions): T { + if (this._isHandlerRunning) { + throw new Error( + `runAsHandler: re-entrant invocation. ` + + `current=${this._current.trace.describe()}, incoming=${t.describe()}` + ); + } + const prev = this._current; + const next: Frame = { trace: t, prev }; + this._current = next; + this._isHandlerRunning = true; + try { + return fn(); + } finally { + this._isHandlerRunning = false; + opts.afterMicrotaskClosure(() => { + // Identity guard: another handler may have run between us + // queuing this reset and it firing. Each runAsHandler mints + // a fresh frame, so reference-equality detects staleness. + if (this._current === next) { this._current = prev; } + }); + } + } + + _resetForTesting(): void { + this._current = ROOT_FRAME; + this._isHandlerRunning = false; + } +} diff --git a/src/vs/base/test/common/virtualScheduling/virtualClock.ts b/src/vs/base/test/common/virtualScheduling/virtualClock.ts new file mode 100644 index 00000000000000..559ec586540c1f --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/virtualClock.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { compareBy, numberComparator, tieBreakComparators } from '../../../common/arrays.js'; +import { Emitter } from '../../../common/event.js'; +import { IDisposable } from '../../../common/lifecycle.js'; +import { Trace } from './trace.js'; + +export type VirtualTime = number; + +/** Debug source description for an event. */ +export interface EventSource { + toString(): string; + readonly stackTrace?: string; +} + +/** + * A unit of work scheduled at a point in virtual time. + * + * Timer callbacks are events. External completions (e.g. fake fs reads) can + * also be modelled as events whose virtual completion time is chosen by a + * scheduling policy — to the {@link VirtualClock} they are indistinguishable. + */ +export interface VirtualEvent { + readonly time: VirtualTime; + readonly source: EventSource; + readonly trace?: Trace; + /** + * Hint for the {@link Embedding}: this event prefers to run on a real + * animation frame (e.g. so DOM measurements after it observe a real + * reflow). Pure-time tests can ignore the hint. + */ + readonly preferRealAnimationFrame?: boolean; + run(): void; +} + +interface QueuedEvent extends VirtualEvent { readonly id: number } + +const eventComparator = tieBreakComparators( + compareBy(e => e.time, numberComparator), + compareBy(e => e.id, numberComparator), +); + +/** + * A pure data structure: a virtual clock + a priority queue of events. + * + * The clock has no concept of "real time". It is advanced exclusively by + * {@link runNext}, which sets `now` to the next event's `time` before running + * it. The {@link VirtualTimeProcessor} is the only intended driver, but the + * clock is useful in isolation (e.g. for unit-testing a scheduler or for + * stepping a scenario manually). + */ +export class VirtualClock { + private _now: VirtualTime; + private _idCounter = 0; + private readonly _queue = new SimplePriorityQueue(eventComparator); + private readonly _onEventScheduled = new Emitter(); + + public readonly onEventScheduled = this._onEventScheduled.event; + + constructor(startTime: VirtualTime = 0) { + this._now = startTime; + } + + get now(): VirtualTime { return this._now; } + get hasEvents(): boolean { return this._queue.length > 0; } + + schedule(event: VirtualEvent): IDisposable { + if (event.time < this._now) { + throw new Error(`Scheduled time (${event.time}) must be >= now (${this._now}).`); + } + const queued: QueuedEvent = { ...event, id: this._idCounter++ }; + this._queue.add(queued); + this._onEventScheduled.fire(event); + return { dispose: () => this._queue.remove(queued) }; + } + + peekNext(): VirtualEvent | undefined { return this._queue.getMin(); } + + runNext(): VirtualEvent | undefined { + const e = this._queue.removeMin(); + if (e) { + this._now = e.time; + e.run(); + } + return e; + } + + getEvents(): readonly VirtualEvent[] { return this._queue.toSortedArray(); } +} + +class SimplePriorityQueue { + private _items: T[] = []; + private _sorted = true; + + constructor(private readonly _compare: (a: T, b: T) => number) { } + + get length(): number { return this._items.length; } + + add(value: T): void { + this._items.push(value); + this._sorted = false; + } + + remove(value: T): void { + const i = this._items.indexOf(value); + if (i !== -1) { this._items.splice(i, 1); } + } + + getMin(): T | undefined { this._ensureSorted(); return this._items[0]; } + removeMin(): T | undefined { this._ensureSorted(); return this._items.shift(); } + toSortedArray(): T[] { this._ensureSorted(); return [...this._items]; } + + private _ensureSorted(): void { + if (this._sorted) { return; } + this._items.sort(this._compare); + this._sorted = true; + } +} diff --git a/src/vs/base/test/common/virtualScheduling/virtualScheduling.test.ts b/src/vs/base/test/common/virtualScheduling/virtualScheduling.test.ts new file mode 100644 index 00000000000000..a2b721cca5f891 --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/virtualScheduling.test.ts @@ -0,0 +1,357 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationTokenSource } from '../../../common/cancellation.js'; +import { DisposableStore } from '../../../common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../utils.js'; +import { + createTraceRoot, + createVirtualTimeApi, + drainMicrotasksEmbedding, + nextMacrotask, + pushGlobalTimeApi, + realTimeApi, + runWithFakedTimers, + Trace, + TraceContext, + untilIdle, + untilTime, + untilToken, + VirtualClock, + VirtualTimeProcessor, +} from './index.js'; + +function traceInfo(t: Trace): { labels: string[]; rootLabel: string; depth: number } { + const labels: string[] = []; + for (let c: Trace | undefined = t; c; c = c.parent) { labels.push(c.label); } + return { labels, rootLabel: t.root.label, depth: t.depth }; +} + +function deferred(): { promise: Promise; resolve: (v: T) => void } { + let resolve!: (v: T) => void; + const promise = new Promise(res => { resolve = res; }); + return { promise, resolve }; +} + +const realSink = { afterMicrotaskClosure: (cb: () => void) => nextMacrotask(realTimeApi, cb) }; + +suite('virtualScheduling - Trace + TraceContext', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => TraceContext.instance._resetForTesting()); + + test('Trace.describe builds causal chain from leaf to root', () => { + const root = createTraceRoot('fixture'); + const t1 = root.child('setTimeout(100ms)'); + const t2 = t1.child('await continuation'); + assert.deepStrictEqual(traceInfo(t2), { + labels: ['await continuation', 'setTimeout(100ms)', 'fixture'], + rootLabel: 'fixture', + depth: 2, + }); + }); + + test('runWithTrace installs and restores synchronously; supports nesting', () => { + const a = createTraceRoot('a'); + const b = createTraceRoot('b'); + const observations: string[] = []; + observations.push(TraceContext.instance.currentTrace().label); + TraceContext.instance.runWithTrace(a, () => { + observations.push(TraceContext.instance.currentTrace().label); + TraceContext.instance.runWithTrace(b, () => { + observations.push(TraceContext.instance.currentTrace().label); + }); + observations.push(TraceContext.instance.currentTrace().label); + }); + observations.push(TraceContext.instance.currentTrace().label); + assert.deepStrictEqual(observations, ['', 'a', 'b', 'a', '']); + }); + + test('runAsHandler throws on sync re-entry', () => { + const a = createTraceRoot('a'); + const b = createTraceRoot('b'); + assert.throws( + () => TraceContext.instance.runAsHandler(a, + () => TraceContext.instance.runAsHandler(b, () => { }, realSink), + realSink), + /re-entrant/, + ); + }); + + test('runAsHandler leaks trace across awaited microtasks', async () => { + const root = createTraceRoot('fixture'); + const observed: string[] = []; + + await TraceContext.instance.runAsHandler(root, async () => { + observed.push(TraceContext.instance.currentTrace().label); + await Promise.resolve(); + observed.push(TraceContext.instance.currentTrace().label); + await Promise.resolve().then(() => Promise.resolve()); + observed.push(TraceContext.instance.currentTrace().label); + }, realSink); + + assert.deepStrictEqual(observed, ['fixture', 'fixture', 'fixture']); + }); +}); + +suite('virtualScheduling - createVirtualTimeApi trace propagation', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => TraceContext.instance._resetForTesting()); + + test('virtual setTimeout: callback fires under trace child of schedule-time trace', async () => { + await runWithFakedTimers({}, async () => { + const root = createTraceRoot('root'); + const { promise, resolve } = deferred(); + TraceContext.instance.runAsHandler(root, () => { + setTimeout(() => resolve(TraceContext.instance.currentTrace()), 0); + }, realSink); + const observed = await promise; + assert.deepStrictEqual(traceInfo(observed), { + labels: ['setTimeout(0ms)', 'root'], + rootLabel: 'root', + depth: 1, + }); + }); + }); + + test('virtual nested setTimeout preserves full causal chain', async () => { + await runWithFakedTimers({}, async () => { + const root = createTraceRoot('root'); + const { promise, resolve } = deferred(); + TraceContext.instance.runAsHandler(root, () => { + setTimeout(() => { + setTimeout(() => resolve(TraceContext.instance.currentTrace()), 0); + }, 0); + }, realSink); + const observed = await promise; + assert.deepStrictEqual(traceInfo(observed), { + labels: ['setTimeout(0ms)', 'setTimeout(0ms)', 'root'], + rootLabel: 'root', + depth: 2, + }); + }); + }); + + test('virtual setInterval: each tick gets a fresh child trace', async () => { + await runWithFakedTimers({}, async () => { + const root = createTraceRoot('root'); + const observed: Trace[] = []; + const { promise, resolve } = deferred(); + TraceContext.instance.runAsHandler(root, () => { + const id = setInterval(() => { + observed.push(TraceContext.instance.currentTrace()); + if (observed.length === 3) { clearInterval(id); resolve(); } + }, 5); + }, realSink); + await promise; + assert.deepStrictEqual(observed.map(traceInfo), [ + { labels: ['tick #1', 'setInterval(5ms)', 'root'], rootLabel: 'root', depth: 2 }, + { labels: ['tick #2', 'setInterval(5ms)', 'root'], rootLabel: 'root', depth: 2 }, + { labels: ['tick #3', 'setInterval(5ms)', 'root'], rootLabel: 'root', depth: 2 }, + ]); + }); + }); + + test('concurrent runAsHandler via setTimeout(0): traces do not leak across handlers', async () => { + await runWithFakedTimers({}, async () => { + const a = createTraceRoot('a'); + const b = createTraceRoot('b'); + const { promise: doneA, resolve: resA } = deferred(); + const { promise: doneB, resolve: resB } = deferred(); + TraceContext.instance.runAsHandler(a, () => { + setTimeout(() => resA(TraceContext.instance.currentTrace()), 0); + }, realSink); + TraceContext.instance.runAsHandler(b, () => { + setTimeout(() => resB(TraceContext.instance.currentTrace()), 0); + }, realSink); + const [tA, tB] = await Promise.all([doneA, doneB]); + assert.deepStrictEqual({ + aRoot: tA.root.label, + aLabels: traceInfo(tA).labels, + bRoot: tB.root.label, + bLabels: traceInfo(tB).labels, + }, { + aRoot: 'a', + aLabels: ['setTimeout(0ms)', 'a'], + bRoot: 'b', + bLabels: ['setTimeout(0ms)', 'b'], + }); + }); + }); +}); + +suite('virtualScheduling - VirtualTimeProcessor termination policies', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => TraceContext.instance._resetForTesting()); + + function makeProcessor(store: DisposableStore, clock: VirtualClock): VirtualTimeProcessor { + return store.add(new VirtualTimeProcessor( + clock, + drainMicrotasksEmbedding(realTimeApi), + realTimeApi, + { defaultMaxEvents: 50 }, + )); + } + + test('untilIdle: resolves when queue drains', async () => { + const store = new DisposableStore(); + const clock = new VirtualClock(); + const p = makeProcessor(store, clock); + const log: string[] = []; + + clock.schedule({ time: 5, source: { toString: () => 't1' }, run: () => log.push('a') }); + clock.schedule({ time: 10, source: { toString: () => 't2' }, run: () => log.push('b') }); + + await p.run({ until: untilIdle }); + assert.deepStrictEqual(log, ['a', 'b']); + store.dispose(); + }); + + test('untilTime: resolves at deadline even when no events scheduled', async () => { + // The deadline alone — with no other events queued — must still drive + // virtual time to the deadline. The processor inserts a sentinel + // event at the deadline to guarantee this. + const store = new DisposableStore(); + const clock = new VirtualClock(); + const p = makeProcessor(store, clock); + + await p.run({ until: untilTime(100) }); + assert.strictEqual(clock.now, 100); + store.dispose(); + }); + + test('untilTime: pre-scheduled events run before deadline', async () => { + const store = new DisposableStore(); + const clock = new VirtualClock(); + const p = makeProcessor(store, clock); + const log: string[] = []; + + clock.schedule({ time: 50, source: { toString: () => 't' }, run: () => log.push('a') }); + + await p.run({ until: untilTime(100) }); + assert.deepStrictEqual({ log, virtualNow: clock.now }, { log: ['a'], virtualNow: 100 }); + store.dispose(); + }); + + test('untilTime: events strictly past the deadline are NOT executed', async () => { + const store = new DisposableStore(); + const clock = new VirtualClock(); + const p = makeProcessor(store, clock); + const log: string[] = []; + + clock.schedule({ time: 50, source: { toString: () => 'a' }, run: () => log.push('a') }); + clock.schedule({ time: 100, source: { toString: () => 'b' }, run: () => log.push('b') }); + clock.schedule({ time: 101, source: { toString: () => 'c' }, run: () => log.push('c') }); + + await p.run({ until: untilTime(100) }); + assert.deepStrictEqual(log, ['a', 'b']); + store.dispose(); + }); + + test('untilToken: resolves only after token cancellation AND drain', async () => { + const store = new DisposableStore(); + const clock = new VirtualClock(); + const p = makeProcessor(store, clock); + const cts = store.add(new CancellationTokenSource()); + const log: string[] = []; + + const runP = p.run({ until: untilToken(cts.token) }); + + // While run is parked (no events), schedule + cancel. + await Promise.resolve(); + clock.schedule({ time: 5, source: { toString: () => 't' }, run: () => log.push('a') }); + cts.cancel(); + + await runP; + assert.deepStrictEqual(log, ['a']); + store.dispose(); + }); + + test('maxEvents: rejects when too many events are executed', async () => { + const store = new DisposableStore(); + const clock = new VirtualClock(); + const p = makeProcessor(store, clock); + + // Self-rescheduling timer + const tick = (n: number) => { + clock.schedule({ + time: clock.now + 1, + source: { toString: () => `t${n}` }, + run: () => tick(n + 1), + }); + }; + tick(0); + + await assert.rejects( + p.run({ until: untilIdle, maxEvents: 5 }), + /exceeded maxEvents/, + ); + store.dispose(); + }); + + test('disposal rejects all active runs', async () => { + const store = new DisposableStore(); + const clock = new VirtualClock(); + const p = makeProcessor(store, clock); + const cts = store.add(new CancellationTokenSource()); + + const runP = p.run({ until: untilToken(cts.token) }); + p.dispose(); + + await assert.rejects(runP, /disposed/); + store.dispose(); + }); +}); + +suite('virtualScheduling - runWithFakedTimers', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => TraceContext.instance._resetForTesting()); + + test('drains queue after fn() resolves', async () => { + const log: string[] = []; + await runWithFakedTimers({}, async () => { + setTimeout(() => log.push('a'), 100); + setTimeout(() => log.push('b'), 50); + }); + assert.deepStrictEqual(log, ['b', 'a']); + }); + + test('useFakeTimers=false bypasses virtual time', async () => { + const before = globalThis.setTimeout; + await runWithFakedTimers({ useFakeTimers: false }, async () => { + assert.strictEqual(globalThis.setTimeout, before); + }); + }); + + test('promise chains awaited inside fn() resolve deterministically', async () => { + const log: string[] = []; + await runWithFakedTimers({}, async () => { + await new Promise(resolve => { + setTimeout(async () => { + log.push('1'); + await Promise.resolve(); + log.push('2'); + setTimeout(() => { log.push('3'); resolve(); }, 10); + }, 5); + }); + }); + assert.deepStrictEqual(log, ['1', '2', '3']); + }); +}); + +suite('virtualScheduling - createVirtualTimeApi without processor', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('virtual Date.now() returns clock time', () => { + const clock = new VirtualClock(12345); + const api = createVirtualTimeApi(clock); + const restore = pushGlobalTimeApi(api); + try { + assert.strictEqual(Date.now(), 12345); + } finally { + restore.dispose(); + } + }); +}); diff --git a/src/vs/base/test/common/virtualScheduling/virtualTimeApi.ts b/src/vs/base/test/common/virtualScheduling/virtualTimeApi.ts new file mode 100644 index 00000000000000..454b973845d942 --- /dev/null +++ b/src/vs/base/test/common/virtualScheduling/virtualTimeApi.ts @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../common/lifecycle.js'; +import { realTimeApi, TimeApi } from './timeApi.js'; +import { ROOT_TRACE, TraceContext } from './trace.js'; +import { VirtualClock } from './virtualClock.js'; + +/** Virtual timer IDs are `IDisposable`s. Recover one from an opaque id. */ +function asDisposable(id: unknown): IDisposable | undefined { + if (id === null || typeof id !== 'object') { return undefined; } + const maybe = id as Partial; + return typeof maybe.dispose === 'function' ? id as IDisposable : undefined; +} + +export interface CreateVirtualTimeApiOptions { + /** + * If `true`, `requestAnimationFrame` is faked: callbacks are scheduled + * onto the virtual queue at `now + 16ms` and the resulting event hints + * the embedding to use a real `requestAnimationFrame` so the host can + * reflow before the callback runs. Useful for fixtures that need DOM + * measurements after rAF callbacks. + * + * If `false` (default), `requestAnimationFrame` is left to the host. + */ + readonly fakeRequestAnimationFrame?: boolean; +} + +/** + * Build a {@link TimeApi} that schedules every timer call into `clock`'s + * virtual queue, capturing the current trace at schedule time so that + * causal chains (`setTimeout` → `setTimeout`, etc.) are preserved. + * + * The returned API is suitable to install with {@link pushGlobalTimeApi}, + * which is what {@link runWithFakedTimers} does internally. + */ +export function createVirtualTimeApi( + clock: VirtualClock, + options?: CreateVirtualTimeApiOptions, +): TimeApi { + + function virtualSetTimeout(handler: () => void, timeout: number = 0): IDisposable { + const stack = new Error().stack; + const trace = TraceContext.instance.currentTrace().child(`setTimeout(${timeout}ms)`, stack); + return clock.schedule({ + time: clock.now + timeout, + run: () => { handler(); }, + source: { toString: () => 'setTimeout', stackTrace: stack }, + trace, + }); + } + + function virtualClearTimeout(id: unknown): void { + asDisposable(id)?.dispose(); + } + + function virtualSetInterval(handler: () => void, interval: number): IDisposable { + const stack = new Error().stack; + const baseTrace = TraceContext.instance.currentTrace().child(`setInterval(${interval}ms)`, stack); + let iter = 0; + let disposed = false; + let lastDisposable: IDisposable; + + const arm = (): void => { + iter++; + const myIter = iter; + lastDisposable = clock.schedule({ + time: clock.now + interval, + run: () => { + if (disposed) { return; } + arm(); // schedule the next tick first, so a throwing + handler(); // handler doesn't kill the interval + }, + source: { toString: () => `setInterval (iteration ${myIter})`, stackTrace: stack }, + trace: baseTrace.child(`tick #${myIter}`), + }); + }; + + arm(); + return { + dispose: () => { + if (disposed) { return; } + disposed = true; + lastDisposable.dispose(); + }, + }; + } + + function virtualClearInterval(id: unknown): void { + asDisposable(id)?.dispose(); + } + + // A faux `Date` that returns virtual time from `now()` and uses virtual + // time as the default constructor argument; everything else delegates. + // The `Date` constructor is an exotic object whose call/construct + // signatures aren't expressible without a structural mismatch — we go + // through `unknown` for the tagging mutations rather than widening to + // `any`. + const OriginalDate = realTimeApi.Date; + // `Date` is overloaded (zero-arg, one-arg, multi-arg). `ConstructorParameters` + // only sees the last overload, so we type args as `unknown[]` and forward + // them through a typed cast at the call site. + function VirtualDate(this: unknown, ...args: unknown[]): unknown { + if (!(this instanceof VirtualDate)) { + return new OriginalDate(clock.now).toString(); + } + if (args.length === 0) { + return new OriginalDate(clock.now); + } + return new (OriginalDate as new (...a: unknown[]) => Date)(...args); + } + // Static-property tagging. Use a typed `Record` view of the function + // rather than `any`. We skip non-writable own properties (`length`, + // `name` on a function would throw) and then explicitly set the few + // statics callers reach for. + const dateStatics = VirtualDate as unknown as Record; + const originalStatics = OriginalDate as unknown as Record; + for (const key of Object.getOwnPropertyNames(OriginalDate)) { + const desc = Object.getOwnPropertyDescriptor(OriginalDate, key); + if (desc && (desc.writable || desc.set)) { + dateStatics[key] = originalStatics[key]; + } + } + dateStatics.now = () => clock.now; + dateStatics.parse = OriginalDate.parse; + dateStatics.UTC = OriginalDate.UTC; + VirtualDate.prototype = OriginalDate.prototype; + + const api: TimeApi = { + setTimeout: virtualSetTimeout as unknown as TimeApi['setTimeout'], + clearTimeout: virtualClearTimeout, + setInterval: virtualSetInterval as unknown as TimeApi['setInterval'], + clearInterval: virtualClearInterval, + Date: VirtualDate as unknown as DateConstructor, + }; + + // Expose the real setTimeout as `originalFn` on the virtual one. The + // component-explorer host's polling loop reads this to escape virtual + // time when waiting for renders to settle. + (api.setTimeout as unknown as { originalFn: TimeApi['setTimeout'] }).originalFn = realTimeApi.setTimeout; + + if (options?.fakeRequestAnimationFrame) { + let rafIdCounter = 0; + const rafDisposables = new Map(); + + api.requestAnimationFrame = ((callback: (time: number) => void) => { + const id = ++rafIdCounter; + const stack = new Error().stack; + const trace = TraceContext.instance.currentTrace().child('requestAnimationFrame', stack); + const d = clock.schedule({ + time: clock.now + 16, + preferRealAnimationFrame: true, + run: () => { + rafDisposables.delete(id); + callback(clock.now); + }, + source: { toString: () => 'requestAnimationFrame', stackTrace: stack }, + trace, + }); + rafDisposables.set(id, d); + return id; + }) as TimeApi['requestAnimationFrame']; + + api.cancelAnimationFrame = ((id: number) => { + const d = rafDisposables.get(id); + if (d) { + d.dispose(); + rafDisposables.delete(id); + } + }) as TimeApi['cancelAnimationFrame']; + } + + // Trace defaults: ensure handlers fired inside virtual time get the + // current trace at *schedule* time, not at fire time. The processor + // already wraps execution in `runAsHandler`, so when virtual events + // fire inside the processor the trace is set correctly. This block is + // just a safety net for callers that step the clock manually. + void ROOT_TRACE; + + return api; +} + +// Re-exported for convenience: many tests want to install both at once. +export { pushGlobalTimeApi } from './globalTimeApi.js'; diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index a3f632a34cde00..510ab589dc292e 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -206,6 +206,9 @@ const _allApiProposals = { css: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.css.d.ts', }, + customEditorDiffs: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorDiffs.d.ts', + }, customEditorMove: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts', }, diff --git a/src/vs/platform/userDataProfile/common/userDataProfile.ts b/src/vs/platform/userDataProfile/common/userDataProfile.ts index e7c24ed6abde96..dacd956eb8a098 100644 --- a/src/vs/platform/userDataProfile/common/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/common/userDataProfile.ts @@ -24,6 +24,7 @@ import { isString, Mutable } from '../../../base/common/types.js'; export const AGENTS_WINDOW_PROFILE_ID = 'agents'; const AGENTS_WINDOW_PROFILE_OPTIONS: IUserDataProfileOptions = { useDefaultFlags: { + settings: true, keybindings: true, prompts: true, mcp: true, diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index ba8cd08f80baba..456b988680466c 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -25,15 +25,9 @@ import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base import { IAction, Separator } from '../../../../base/common/actions.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IHostService } from '../../../../workbench/services/host/browser/host.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { URI } from '../../../../base/common/uri.js'; -import { isWindows, isMacintosh } from '../../../../base/common/platform.js'; -import { UpdateHoverWidget } from './updateHoverWidget.js'; +import { registerUpdateTitleBarMenuPlacement } from '../../../../workbench/contrib/update/browser/updateTitleBarEntry.js'; import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ChatStatusDashboard, IChatStatusDashboardOptions } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -50,7 +44,6 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta // --- Account Menu Items --- // const AccountMenu = Menus.AccountMenu; const SessionsTitleBarAccountWidgetAction = 'sessions.action.titleBarAccountWidget'; -const SessionsTitleBarUpdateWidgetAction = 'sessions.action.titleBarUpdateWidget'; const SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH = 360; const PERSONALIZE_ACTION_IDS: readonly string[] = [ @@ -61,106 +54,12 @@ const PERSONALIZE_ACTION_IDS: readonly string[] = [ const SIGN_OUT_ACTION_ID = 'workbench.action.agenticSignOut'; const SIGN_IN_ACTION_ID = 'workbench.action.agenticSignIn'; -function shouldHideSessionsTitleBarUpdateWidget(type: StateType): boolean { - return type === StateType.Uninitialized - || type === StateType.Idle - || type === StateType.Disabled - || type === StateType.CheckingForUpdates; -} - -function isPrimarySessionsTitleBarUpdateWidget(type: StateType): boolean { - return type === StateType.AvailableForDownload - || type === StateType.Downloaded - || type === StateType.Ready; -} - -function isBusySessionsTitleBarUpdateWidget(type: StateType): boolean { - return type === StateType.Downloading - || type === StateType.Overwriting - || type === StateType.Updating - || type === StateType.Restarting; -} - -function getSessionsTitleBarUpdateLabel(state: State): string { - switch (state.type) { - case StateType.AvailableForDownload: - return localize('sessionsTitleBarUpdateAvailable', "Update Available"); - case StateType.Downloaded: - return localize('sessionsTitleBarInstallUpdate', "Install Update"); - case StateType.Ready: - return localize('sessionsTitleBarRestartToUpdate', "Restart to Update"); - case StateType.Downloading: - case StateType.Overwriting: - return localize('sessionsTitleBarDownloading', "Downloading..."); - case StateType.Updating: - case StateType.Restarting: - return localize('sessionsTitleBarInstalling', "Installing..."); - default: - return localize('sessionsTitleBarUpdate', "Update"); - } -} - -function getSessionsTitleBarUpdateAriaLabel(state: State): string { - switch (state.type) { - case StateType.AvailableForDownload: - return localize('sessionsTitleBarUpdateAvailableAria', "Update available"); - case StateType.Downloaded: - return localize('sessionsTitleBarInstallUpdateAria', "Install downloaded update"); - case StateType.Ready: - return localize('sessionsTitleBarRestartToUpdateAria', "Restart to apply update"); - case StateType.Downloading: - case StateType.Overwriting: - return localize('sessionsTitleBarDownloadingAria', "Update download in progress"); - case StateType.Updating: - case StateType.Restarting: - return localize('sessionsTitleBarInstallingAria', "Update install in progress"); - default: - return localize('sessionsTitleBarUpdateAria', "Update"); - } -} - -async function runSessionsUpdateAction( - state: State, - updateService: IUpdateService, - openerService: IOpenerService, - productService: IProductService, - dialogService: IDialogService, - hostService: IHostService, -): Promise { - if (state.type === StateType.AvailableForDownload) { - const isInsiderOrExploration = productService.quality === 'insider' || productService.quality === 'exploration'; - const hasCrossAppCoordinator = (isWindows || isMacintosh) && isInsiderOrExploration; - if (!hasCrossAppCoordinator) { - const { confirmed } = await dialogService.confirm({ - message: localize('sessionsUpdateFromVSCode.title', "Update from VS Code"), - detail: localize('sessionsUpdateFromVSCode.detail', "This will close the Agents app and open VS Code so you can install the update.\n\nLaunch Agents again after the update is complete."), - primaryButton: localize('sessionsUpdateFromVSCode.open', "Close and Open VS Code"), - }); - - if (confirmed) { - await openerService.open(URI.from({ - scheme: productService.urlProtocol, - query: 'windowId=_blank', - }), { openExternal: true }); - await hostService.shutdown(); - } - - return; - } - - await updateService.downloadUpdate(true); - return; - } - - if (state.type === StateType.Ready) { - await updateService.quitAndInstall(); - return; - } - - if (state.type === StateType.Downloaded) { - await updateService.applyUpdate(); - } -} +// Register the shared VS Code update title bar entry into the Agents titlebar layout. +registerUpdateTitleBarMenuPlacement(Menus.TitleBarRightLayout, { + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + group: 'navigation', + order: 99, +}); // Sign In (shown when signed out) registerAction2(class extends Action2 { @@ -781,105 +680,11 @@ class TitleBarAccountWidget extends BaseActionViewItem { } } -class TitleBarUpdateWidget extends BaseActionViewItem { - - private container: HTMLElement | undefined; - private labelElement: HTMLElement | undefined; - private readonly updateHoverWidget: UpdateHoverWidget; - private readonly hoverAttachment = this._register(new MutableDisposable()); - - constructor( - action: IAction, - options: IBaseActionViewItemOptions | undefined, - @IUpdateService private readonly updateService: IUpdateService, - @IHoverService private readonly hoverService: IHoverService, - @IProductService private readonly productService: IProductService, - @IOpenerService private readonly openerService: IOpenerService, - @IDialogService private readonly dialogService: IDialogService, - @IHostService private readonly hostService: IHostService, - ) { - super(undefined, action, options); - this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); - this._register(this.updateService.onStateChange(() => this.renderState())); - } - - override render(container: HTMLElement): void { - super.render(container); - - this.container = container; - container.classList.add('sessions-update-titlebar-widget'); - container.setAttribute('role', 'button'); - - this.labelElement = append(container, $('span.sessions-update-titlebar-widget-label')); - this.hoverAttachment.value = this.updateHoverWidget.attachTo(container); - - this.renderState(); - } - - override onClick(): void { - const state = this.updateService.state; - if (shouldHideSessionsTitleBarUpdateWidget(state.type) || isBusySessionsTitleBarUpdateWidget(state.type)) { - return; - } - - void runSessionsUpdateAction( - state, - this.updateService, - this.openerService, - this.productService, - this.dialogService, - this.hostService, - ); - } - - private renderState(): void { - if (!this.container || !this.labelElement) { - return; - } - - const state = this.updateService.state; - const hidden = shouldHideSessionsTitleBarUpdateWidget(state.type); - const busy = isBusySessionsTitleBarUpdateWidget(state.type); - const primary = isPrimarySessionsTitleBarUpdateWidget(state.type); - - this.container.classList.toggle('hidden', hidden); - this.container.classList.toggle('disabled', busy); - this.container.classList.toggle('primary-state', primary); - this.container.classList.toggle('busy-state', busy); - - if (hidden) { - this.container.removeAttribute('aria-label'); - this.labelElement.textContent = ''; - return; - } - - this.container.setAttribute('aria-label', getSessionsTitleBarUpdateAriaLabel(state)); - this.labelElement.textContent = getSessionsTitleBarUpdateLabel(state); - } -} - // --- Register custom view item --- // // Actions registered at module level so Menus.TitleBarRightLayout is non-empty when the // toolbar is first constructed. The run() is a no-op — rendering is handled by the custom // view items registered in AccountWidgetContribution. -registerAction2(class extends Action2 { - constructor() { - super({ - id: SessionsTitleBarUpdateWidgetAction, - title: localize2('agentsUpdateTitleBar', "Agents Update"), - menu: { - id: Menus.TitleBarRightLayout, - group: 'navigation', - order: 99, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), - } - }); - } - - run(): void { } -}); - registerAction2(class extends Action2 { constructor() { super({ @@ -907,10 +712,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu ) { super(); - this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarUpdateWidgetAction, (action, options) => { - return instantiationService.createInstance(TitleBarUpdateWidget, action, options); - }, undefined)); - this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarAccountWidgetAction, (action, options) => { return instantiationService.createInstance(TitleBarAccountWidget, action, options); }, undefined)); diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index a288ee7a504700..1451d3f057a9db 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -69,64 +69,12 @@ display: none; } -/* Update widget in titlebar */ +/* Spacing between titlebar-right action items (account widget, update indicator, etc.) */ .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .actions-container { gap: 5px; } -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget { - display: flex; - align-items: center; - height: 22px; - min-width: 0; - padding: 0 8px; - border-radius: var(--vscode-cornerRadius-medium); - background: transparent; - color: var(--vscode-foreground); - cursor: pointer; - -webkit-app-region: no-drag; - overflow: hidden; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.hidden { - display: none; -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:hover, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:focus-within { - background: var(--vscode-toolbar-hoverBackground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:active, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state { - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:hover, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:focus-within, -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:active { - background: var(--vscode-button-hoverBackground); - color: var(--vscode-button-foreground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.busy-state { - cursor: default; - color: var(--vscode-descriptionForeground); -} - -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget .sessions-update-titlebar-widget-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - font-weight: 500; - line-height: 16px; - color: inherit; -} - /* Badge on account icon */ .agent-sessions-workbench .sessions-account-titlebar-widget-badge { diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css deleted file mode 100644 index 6291d8e2922509..00000000000000 --- a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css +++ /dev/null @@ -1,65 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.sessions-update-hover { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 200px; - padding: 12px 16px; -} - -.sessions-update-hover-header { - font-weight: 600; - font-size: 13px; -} - -/* Progress bar track */ -.sessions-update-hover-progress-track { - height: 4px; - border-radius: 2px; - background-color: var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.3)); - overflow: hidden; -} - -/* Progress bar fill */ -.sessions-update-hover-progress-fill { - height: 100%; - border-radius: 2px; - background-color: var(--vscode-progressBar-background, #0078d4); - transition: width 0.2s ease; -} - -/* Details grid */ -.sessions-update-hover-grid { - display: grid; - grid-template-columns: auto auto auto auto; - column-gap: 8px; - row-gap: 2px; - font-size: 12px; - align-items: baseline; -} - -.sessions-update-hover-label { - color: var(--vscode-descriptionForeground); -} - -/* Version number emphasis */ -.sessions-update-hover-version { - color: var(--vscode-textLink-foreground); -} - -/* Compact age label */ -.sessions-update-hover-age { - color: var(--vscode-descriptionForeground); - font-size: 11px; -} - -/* Commit hashes - subtle */ -.sessions-update-hover-commit { - color: var(--vscode-descriptionForeground); - font-family: var(--monaco-monospace-font); - font-size: 11px; -} diff --git a/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts deleted file mode 100644 index ebe155ca5c35c6..00000000000000 --- a/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts +++ /dev/null @@ -1,188 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { localize } from '../../../../nls.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { Downloading, IUpdate, IUpdateService, State, StateType, Updating } from '../../../../platform/update/common/update.js'; -import './media/updateHoverWidget.css'; - -export class UpdateHoverWidget { - - constructor( - private readonly updateService: IUpdateService, - private readonly productService: IProductService, - private readonly hoverService: IHoverService, - private readonly stateProvider?: () => State, - ) { } - - attachTo(target: HTMLElement) { - return this.hoverService.setupDelayedHover( - target, - () => ({ - content: this.createHoverContent(), - position: { hoverPosition: HoverPosition.RIGHT }, - appearance: { showPointer: true } - }), - { groupId: 'sessions-account-update' } - ); - } - - createHoverContent(state: State = this.stateProvider?.() ?? this.updateService.state): HTMLElement { - const update = this.getUpdateFromState(state); - const currentVersion = this.productService.version ?? localize('unknownVersion', "Unknown"); - const targetVersion = update?.productVersion ?? update?.version ?? localize('unknownVersion', "Unknown"); - const currentCommit = this.productService.commit; - const targetCommit = update?.version; - const progressPercent = this.getUpdateProgressPercent(state); - - const container = document.createElement('div'); - container.classList.add('sessions-update-hover'); - - // Header: e.g. "Downloading VS Code Insiders" - const header = document.createElement('div'); - header.classList.add('sessions-update-hover-header'); - header.textContent = this.getUpdateHeaderLabel(state.type); - container.appendChild(header); - - // Progress bar - if (progressPercent !== undefined) { - const progressTrack = document.createElement('div'); - progressTrack.classList.add('sessions-update-hover-progress-track'); - const progressFill = document.createElement('div'); - progressFill.classList.add('sessions-update-hover-progress-fill'); - progressFill.style.width = `${progressPercent}%`; - progressTrack.appendChild(progressFill); - container.appendChild(progressTrack); - } - - // Version info grid - const detailsGrid = document.createElement('div'); - detailsGrid.classList.add('sessions-update-hover-grid'); - - const currentDate = this.productService.date ? new Date(this.productService.date) : undefined; - const currentAge = currentDate ? this.formatCompactAge(currentDate.getTime()) : undefined; - const newAge = update?.timestamp ? this.formatCompactAge(update.timestamp) : undefined; - - this.appendGridRow(detailsGrid, localize('updateHoverCurrentVersionLabel', "Current"), currentVersion, currentAge, currentCommit); - this.appendGridRow(detailsGrid, localize('updateHoverNewVersionLabel', "New"), targetVersion, newAge, targetCommit); - - container.appendChild(detailsGrid); - - return container; - } - - private appendGridRow(grid: HTMLElement, label: string, version: string, age?: string, commit?: string): void { - const labelEl = document.createElement('span'); - labelEl.classList.add('sessions-update-hover-label'); - labelEl.textContent = label; - grid.appendChild(labelEl); - - const versionEl = document.createElement('span'); - versionEl.classList.add('sessions-update-hover-version'); - versionEl.textContent = version; - grid.appendChild(versionEl); - - const ageEl = document.createElement('span'); - ageEl.classList.add('sessions-update-hover-age'); - ageEl.textContent = age ?? ''; - grid.appendChild(ageEl); - - const commitEl = document.createElement('span'); - commitEl.classList.add('sessions-update-hover-commit'); - commitEl.textContent = commit ? commit.substring(0, 7) : ''; - grid.appendChild(commitEl); - } - - private formatCompactAge(timestamp: number): string { - const seconds = Math.round((Date.now() - timestamp) / 1000); - if (seconds < 60) { - return localize('compactAgeNow', "now"); - } - const minutes = Math.round(seconds / 60); - if (minutes < 60) { - return localize('compactAgeMinutes', "{0}m ago", minutes); - } - const hours = Math.round(seconds / 3600); - if (hours < 24) { - return localize('compactAgeHours', "{0}h ago", hours); - } - const days = Math.round(seconds / 86400); - if (days < 7) { - return localize('compactAgeDays', "{0}d ago", days); - } - const weeks = Math.round(days / 7); - if (weeks < 5) { - return localize('compactAgeWeeks', "{0}w ago", weeks); - } - const months = Math.round(days / 30); - return localize('compactAgeMonths', "{0}mo ago", months); - } - - private getUpdateFromState(state: State): IUpdate | undefined { - switch (state.type) { - case StateType.AvailableForDownload: - case StateType.Downloaded: - case StateType.Ready: - case StateType.Overwriting: - case StateType.Updating: - return state.update; - case StateType.Downloading: - return state.update; - default: - return undefined; - } - } - - /** - * Returns progress as a percentage (0-100), or undefined if progress is not applicable. - */ - private getUpdateProgressPercent(state: State): number | undefined { - switch (state.type) { - case StateType.Downloading: { - const downloadingState = state as Downloading; - if (downloadingState.downloadedBytes !== undefined && downloadingState.totalBytes && downloadingState.totalBytes > 0) { - return Math.min(100, Math.round((downloadingState.downloadedBytes / downloadingState.totalBytes) * 100)); - } - return 0; - } - case StateType.Updating: { - const updatingState = state as Updating; - if (updatingState.currentProgress !== undefined && updatingState.maxProgress && updatingState.maxProgress > 0) { - return Math.min(100, Math.round((updatingState.currentProgress / updatingState.maxProgress) * 100)); - } - return 0; - } - case StateType.Downloaded: - case StateType.Ready: - return 100; - case StateType.AvailableForDownload: - case StateType.Overwriting: - return 0; - default: - return undefined; - } - } - - private getUpdateHeaderLabel(type: StateType): string { - const productName = this.productService.nameShort; - switch (type) { - case StateType.Ready: - return localize('updateReady', "{0} Update Ready", productName); - case StateType.AvailableForDownload: - return localize('downloadAvailable', "{0} Update Available", productName); - case StateType.Downloading: - case StateType.Overwriting: - return localize('downloadingUpdate', "Downloading {0}", productName); - case StateType.Downloaded: - return localize('installingUpdate', "Installing {0}", productName); - case StateType.Updating: - return localize('updatingApp', "Updating {0}", productName); - default: - return localize('updating', "Updating {0}", productName); - } - } -} diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts deleted file mode 100644 index e18a4c0a89f6df..00000000000000 --- a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts +++ /dev/null @@ -1,83 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from '../../../../../base/common/event.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; -import { UpdateHoverWidget } from '../../browser/updateHoverWidget.js'; - -const mockUpdate = { version: 'a1b2c3d4e5f6', productVersion: '1.100.0', timestamp: Date.now() - 2 * 60 * 60 * 1000 }; -const mockUpdateSameVersion = { version: 'a1b2c3d4e5f6', productVersion: '1.99.0', timestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 }; - -function createMockUpdateService(state: State): IUpdateService { - const onStateChange = new Emitter(); - const service: IUpdateService = { - _serviceBrand: undefined, - state, - onStateChange: onStateChange.event, - checkForUpdates: async () => { }, - downloadUpdate: async () => { }, - applyUpdate: async () => { }, - quitAndInstall: async () => { }, - isLatestVersion: async () => true, - _applySpecificUpdate: async () => { }, - setInternalOrg: async () => { }, - }; - return service; -} - -function renderHoverWidget(ctx: ComponentFixtureContext, state: State): void { - ctx.container.style.backgroundColor = 'var(--vscode-editorHoverWidget-background)'; - - const instantiationService = createEditorServices(ctx.disposableStore, { - colorTheme: ctx.theme, - }); - - const updateService = createMockUpdateService(state); - const productService = new class extends mock() { - override readonly version = '1.99.0'; - override readonly nameShort = 'VS Code Insiders'; - override readonly commit = 'f0e1d2c3b4a5'; - override readonly date = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - }; - const hoverService = instantiationService.get(IHoverService); - const widget = new UpdateHoverWidget(updateService, productService, hoverService); - ctx.container.appendChild(widget.createHoverContent(state)); -} - -export default defineThemedFixtureGroup({ path: 'sessions/' }, { - UpdateHoverReady: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdate, true, false)), - }), - - UpdateHoverAvailableForDownload: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderHoverWidget(ctx, State.AvailableForDownload(mockUpdate)), - }), - - UpdateHoverDownloading30Percent: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderHoverWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000)), - }), - - UpdateHoverInstalling: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderHoverWidget(ctx, State.Downloaded(mockUpdate, true, false)), - }), - - UpdateHoverUpdating: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderHoverWidget(ctx, State.Updating(mockUpdate, true, 40, 100)), - }), - - UpdateHoverSameVersion: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdateSameVersion, true, false)), - }), -}); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index b9597dd4d37a6f..63396e95ea1725 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -21,7 +21,7 @@ import './openInVSCodeWidget.js'; import './nullChatTipService.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ISessionsConfigurationService, SessionsConfigurationService } from './sessionsConfigurationService.js'; +import { ISessionsTasksService, SessionsTasksService } from './sessionsTasksService.js'; import { AgenticPromptsService } from './promptsService.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; @@ -154,7 +154,7 @@ registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, Session // register services registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); -registerSingleton(ISessionsConfigurationService, SessionsConfigurationService, InstantiationType.Delayed); +registerSingleton(ISessionsTasksService, SessionsTasksService, InstantiationType.Delayed); registerSingleton(IAICustomizationWorkspaceService, SessionsAICustomizationWorkspaceService, InstantiationType.Delayed); registerSingleton(ICustomizationHarnessService, SessionsCustomizationHarnessService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index 6eba6406666361..f7bedbc18a9915 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -34,7 +34,7 @@ import { IsActiveSessionBackgroundProviderContext, SessionsWelcomeVisibleContext import { ISession } from '../../../services/sessions/common/session.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { Menus } from '../../../browser/menus.js'; -import { INonSessionTaskEntry, ISessionsConfigurationService, ISessionTaskWithTarget, ITaskEntry, TaskStorageTarget } from './sessionsConfigurationService.js'; +import { INonSessionTaskEntry, ISessionsTasksService, ISessionTaskWithTarget, ITaskEntry, TaskStorageTarget } from './sessionsTasksService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IRunScriptCustomTaskWidgetResult, RunScriptCustomTaskWidget } from './runScriptCustomTaskWidget.js'; @@ -119,7 +119,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, @IKeybindingService _keybindingService: IKeybindingService, @IQuickInputService private readonly _quickInputService: IQuickInputService, - @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, + @ISessionsTasksService private readonly _sessionsConfigService: ISessionsTasksService, @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -496,7 +496,7 @@ class RunScriptActionViewItem extends BaseActionViewItem { private readonly _showCustomCommandInput: (session: ISession, existingTask: INonSessionTaskEntry, mode?: TaskConfigurationMode) => Promise, private readonly _generateNewTask: (session: ISession) => Promise, @ICommandService private readonly _commandService: ICommandService, - @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, + @ISessionsTasksService private readonly _sessionsConfigService: ISessionsTasksService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, diff --git a/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts index dd6a9f7477972d..93bd47780f77d6 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts @@ -16,7 +16,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { localize } from '../../../../nls.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { TaskStorageTarget } from './sessionsConfigurationService.js'; +import { TaskStorageTarget } from './sessionsTasksService.js'; export const WORKTREE_CREATED_RUN_ON = 'worktreeCreated' as const; diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts similarity index 88% rename from src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts rename to src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts index 784c3f4c8ff0a5..246816188ab1b7 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts @@ -62,7 +62,7 @@ interface ITasksJson { tasks?: ITaskEntry[]; } -export interface ISessionsConfigurationService { +export interface ISessionsTasksService { readonly _serviceBrand: undefined; /** @@ -118,9 +118,9 @@ export interface ISessionsConfigurationService { setPinnedTaskLabel(repository: URI | undefined, taskLabel: string | undefined): void; } -export const ISessionsConfigurationService = createDecorator('sessionsConfigurationService'); +export const ISessionsTasksService = createDecorator('sessionsTasksService'); -export class SessionsConfigurationService extends Disposable implements ISessionsConfigurationService { +export class SessionsTasksService extends Disposable implements ISessionsTasksService { declare readonly _serviceBrand: undefined; @@ -146,11 +146,8 @@ export class SessionsConfigurationService extends Disposable implements ISession } getSessionTasks(session: ISession): IObservable { - const repo = this._getSessionRepo(session); - const folder = repo?.workingDirectory ?? repo?.uri; - if (folder) { - this._ensureFileWatch(folder); - } + const folder = this._getSessionFolder(session); + this._ensureFileWatch(folder); // Trigger initial read only when the folder changes; the file watcher handles subsequent updates if (!isEqual(this._lastRefreshedFolder, folder)) { this._lastRefreshedFolder = folder; @@ -353,13 +350,30 @@ export class SessionsConfigurationService extends Disposable implements ISession return session.workspace.get()?.repositories[0]; } + private _getSessionFolder(session: ISession): URI | undefined { + const repo = this._getSessionRepo(session); + return repo?.workingDirectory ?? repo?.uri; + } + private _getTasksJsonUri(session: ISession, target: TaskStorageTarget): URI | undefined { if (target === 'workspace') { - const repo = this._getSessionRepo(session); - const folder = repo?.workingDirectory ?? repo?.uri; - return folder ? joinPath(folder, '.vscode', 'tasks.json') : undefined; + return this._getWorkspaceTasksJsonUri(this._getSessionFolder(session)); } - return joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json'); + return this._getUserTasksJsonUri(); + } + + private _getWorkspaceTasksJsonUri(folder: URI | undefined): URI | undefined { + return folder?.path ? joinPath(folder, '.vscode', 'tasks.json') : undefined; + } + + private _getUserTasksJsonUri(): URI | undefined { + const userSettingsResource = this._preferencesService.userSettingsResource; + if (!userSettingsResource.path) { + return undefined; + } + + const userSettingsFolder = dirname(userSettingsResource); + return userSettingsFolder.path ? joinPath(userSettingsFolder, 'tasks.json') : undefined; } private async _readTasksJson(uri: URI): Promise { @@ -375,8 +389,14 @@ export class SessionsConfigurationService extends Disposable implements ISession return !!task.label; } - private _ensureFileWatch(folder: URI): void { - const tasksUri = joinPath(folder, '.vscode', 'tasks.json'); + private _ensureFileWatch(folder: URI | undefined): void { + const tasksUri = this._getWorkspaceTasksJsonUri(folder); + if (!tasksUri) { + this._watchedResource = undefined; + this._fileWatcher.clear(); + return; + } + if (this._watchedResource && this._watchedResource.toString() === tasksUri.toString()) { return; } @@ -388,11 +408,13 @@ export class SessionsConfigurationService extends Disposable implements ISession disposables.add(this._fileService.watch(tasksUri)); // Also watch user-level tasks.json so that user session tasks changes refresh the observable - const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json'); - disposables.add(this._fileService.watch(userUri)); + const userUri = this._getUserTasksJsonUri(); + if (userUri) { + disposables.add(this._fileService.watch(userUri)); + } disposables.add(this._fileService.onDidFilesChange(e => { - if (e.affects(tasksUri) || e.affects(userUri)) { + if (e.affects(tasksUri) || (userUri && e.affects(userUri))) { this._refreshSessionTasks(folder); } })); @@ -406,15 +428,15 @@ export class SessionsConfigurationService extends Disposable implements ISession return; } - const tasksUri = joinPath(folder, '.vscode', 'tasks.json'); - const tasksJson = await this._readTasksJson(tasksUri); + const tasksUri = this._getWorkspaceTasksJsonUri(folder); + const tasksJson = tasksUri ? await this._readTasksJson(tasksUri) : {}; const sessionTasks: ISessionTaskWithTarget[] = (tasksJson.tasks ?? []) .filter(t => t.inAgents && this._isSupportedTask(t)) .map(t => ({ task: t, target: 'workspace' as TaskStorageTarget })); // Also include user-level session tasks - const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json'); - const userJson = await this._readTasksJson(userUri); + const userUri = this._getUserTasksJsonUri(); + const userJson = userUri ? await this._readTasksJson(userUri) : {}; const userSessionTasks: ISessionTaskWithTarget[] = (userJson.tasks ?? []) .filter(t => t.inAgents && this._isSupportedTask(t)) .map(t => ({ task: t, target: 'user' as TaskStorageTarget })); @@ -423,7 +445,7 @@ export class SessionsConfigurationService extends Disposable implements ISession } private _loadPinnedTaskLabels(): Map { - const raw = this._storageService.get(SessionsConfigurationService._PINNED_TASK_LABELS_KEY, StorageScope.APPLICATION); + const raw = this._storageService.get(SessionsTasksService._PINNED_TASK_LABELS_KEY, StorageScope.APPLICATION); if (raw) { try { return new Map(Object.entries(JSON.parse(raw))); @@ -436,7 +458,7 @@ export class SessionsConfigurationService extends Disposable implements ISession private _savePinnedTaskLabels(): void { this._storageService.store( - SessionsConfigurationService._PINNED_TASK_LABELS_KEY, + SessionsTasksService._PINNED_TASK_LABELS_KEY, JSON.stringify(Object.fromEntries(this._pinnedTaskLabels)), StorageScope.APPLICATION, StorageTarget.USER diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts similarity index 92% rename from src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts rename to src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts index 7c502613ab65f1..ce039944370486 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts @@ -14,7 +14,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../../platform import { IJSONEditingService, IJSONValue } from '../../../../../workbench/services/configuration/common/jsonEditing.js'; import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; -import { INonSessionTaskEntry, ISessionsConfigurationService, SessionsConfigurationService, ITaskEntry } from '../../browser/sessionsConfigurationService.js'; +import { INonSessionTaskEntry, ISessionsTasksService, SessionsTasksService, ITaskEntry } from '../../browser/sessionsTasksService.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { Task } from '../../../../../workbench/contrib/tasks/common/tasks.js'; @@ -94,10 +94,10 @@ function tasksJsonContent(tasks: ITaskEntry[]): string { return JSON.stringify({ version: '2.0.0', tasks }); } -suite('SessionsConfigurationService', () => { +suite('SessionsTasksService', () => { const store = new DisposableStore(); - let service: ISessionsConfigurationService; + let service: ISessionsTasksService; let fileContents: Map; let jsonEdits: { uri: URI; values: IJSONValue[] }[]; let ranTasks: { label: string }[]; @@ -106,6 +106,7 @@ suite('SessionsConfigurationService', () => { let activeSessionObs: ReturnType>; let tasksByLabel: Map; let workspaceFoldersByUri: Map; + let preferencesService: IPreferencesService & { userSettingsResource: URI }; const userSettingsUri = URI.parse('file:///user/settings.json'); const repoUri = URI.parse('file:///repo'); @@ -141,9 +142,10 @@ suite('SessionsConfigurationService', () => { } }); - instantiationService.stub(IPreferencesService, new class extends mock() { + preferencesService = new class extends mock() { override userSettingsResource = userSettingsUri; - }); + }; + instantiationService.stub(IPreferencesService, preferencesService); instantiationService.stub(ITaskService, new class extends mock() { override async getTask(_workspaceFolder: any, alias: string | any) { @@ -171,7 +173,7 @@ suite('SessionsConfigurationService', () => { storageService = store.add(new InMemoryStorageService()); instantiationService.stub(IStorageService, storageService); - service = store.add(instantiationService.createInstance(SessionsConfigurationService)); + service = store.add(instantiationService.createInstance(SessionsTasksService)); }); teardown(() => { @@ -251,6 +253,19 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(readFileCalls.length, 2, 'should read files only once (no duplicate refresh)'); }); + test('getSessionTasks skips workspace tasks when repository URI has no path', async () => { + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(userTasksUri.toString(), tasksJsonContent([ + makeTask('userTask', 'npm run user', true), + ])); + + const session = makeSession({ repository: URI.parse('unknown://workspace') }); + const obs = service.getSessionTasks(session); + + await new Promise(r => setTimeout(r, 10)); + assert.deepStrictEqual(obs.get(), [{ task: makeTask('userTask', 'npm run user', true), target: 'user' }]); + }); + // --- getNonSessionTasks --- test('getNonSessionTasks returns only tasks without inAgents', async () => { @@ -305,6 +320,30 @@ suite('SessionsConfigurationService', () => { ] satisfies INonSessionTaskEntry[]); }); + test('getNonSessionTasks skips workspace tasks when repository URI has no path', async () => { + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(userTasksUri.toString(), tasksJsonContent([ + makeTask('userTask', 'npm run user'), + ])); + + const session = makeSession({ repository: URI.parse('unknown://workspace') }); + const nonSessionTasks = await service.getNonSessionTasks(session); + + assert.deepStrictEqual(nonSessionTasks, [ + { task: { label: 'userTask', type: 'shell', command: 'npm run user' }, target: 'user' }, + ] satisfies INonSessionTaskEntry[]); + }); + + test('user task operations are skipped when user settings URI has no path', async () => { + preferencesService.userSettingsResource = URI.parse('test://settings'); + + const session = makeSession({ repository: repoUri }); + const task = await service.createAndAddTask(undefined, 'npm run dev', session, 'user'); + const nonSessionTasks = await service.getNonSessionTasks(session); + + assert.deepStrictEqual({ task, nonSessionTasks, jsonEdits }, { task: undefined, nonSessionTasks: [], jsonEdits: [] }); + }); + // --- addTaskToSessions --- test('addTaskToSessions writes inAgents: true to the matching task index', async () => { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 4736ced69aaeb9..c1c80d5111dcdc 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -96,6 +96,7 @@ gap: 6px; min-width: 0; overflow: hidden; + opacity: 0.7; } .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-repo { @@ -104,7 +105,24 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - opacity: 0.7; +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-separator { + &::before { + content: '\00B7'; + } +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-branch { + display: flex; + align-items: center; + gap: 2px; + + .codicon { + font-size: 13px; + width: 13px; + height: 13px; + } } /* Provider label (shown for untitled sessions) */ diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index a174012761e2f6..99e69d61c6f0da 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -28,8 +28,8 @@ import { ISessionsProvidersService } from '../../../services/sessions/browser/se import { ISessionsListModelService } from './views/sessionsListModelService.js'; import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { IsSessionArchivedContext, IsSessionPinnedContext, IsSessionReadContext, SessionItemContextMenuId } from './views/sessionsList.js'; -import { basename } from '../../../../base/common/resources.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; /** * Sessions Title Bar Widget - renders the active chat session title @@ -129,10 +129,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const label = this._getActiveSessionLabel(); const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); - const repoDetailLabel = this._getRepositoryDetailLabel(); - const pillLabel = repoLabel ? `${label} ${repoLabel}${repoDetailLabel ? ` (${repoDetailLabel})` : ''}` : label; + const repoBranchLabel = this._getRepositoryBranchLabel(); + // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${repoDetailLabel ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${repoBranchLabel ?? ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -150,30 +150,39 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._container.tabIndex = 0; // Session pill: icon + label + folder together - const sessionPill = $('span.agent-sessions-titlebar-pill'); + const sessionPill = $('div.agent-sessions-titlebar-pill'); // Center group: icon + label + folder - const centerGroup = $('span.agent-sessions-titlebar-center'); + const centerGroup = $('div.agent-sessions-titlebar-center'); // Kind icon at the beginning if (icon) { - const iconEl = $('span.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); + const iconEl = $('div.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); centerGroup.appendChild(iconEl); } // Label - const labelEl = $('span.agent-sessions-titlebar-label'); + const labelEl = $('div.agent-sessions-titlebar-label'); labelEl.textContent = label; centerGroup.appendChild(labelEl); // Folder shown next to the title if (repoLabel) { - const detailsEl = $('span.agent-sessions-titlebar-details'); + const detailsEl = $('div.agent-sessions-titlebar-details'); - const repoEl = $('span.agent-sessions-titlebar-repo'); - repoEl.textContent = repoDetailLabel ? `${repoLabel} (${repoDetailLabel})` : repoLabel; + const repoEl = $('div.agent-sessions-titlebar-repo'); + repoEl.textContent = repoLabel; detailsEl.appendChild(repoEl); + if (repoBranchLabel) { + const separatorEl = $('div.agent-sessions-titlebar-separator'); + detailsEl.appendChild(separatorEl); + + const branchEl = $('div.agent-sessions-titlebar-branch'); + branchEl.append(...renderLabelWithIcons(`$(git-branch) ${repoBranchLabel}`)); + detailsEl.appendChild(branchEl); + } + centerGroup.appendChild(detailsEl); } @@ -198,10 +207,11 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._container.appendChild(sessionPill); // Hover + const hover = `${label}${repoLabel ? `, ${repoLabel}` : ''}${repoBranchLabel ? `, ${repoBranchLabel}` : ''}`; this._dynamicDisposables.add(this.hoverService.setupManagedHover( getDefaultHoverDelegate('mouse'), sessionPill, - pillLabel + hover )); // Keyboard handler @@ -253,33 +263,18 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return undefined; } - private _getRepositoryDetailLabel(): string | undefined { + /** + * Get the branch label for the active session (only when using a worktree). + */ + private _getRepositoryBranchLabel(): string | undefined { const sessionData = this.sessionsManagementService.activeSession.get(); const workspace = sessionData?.workspace.get(); const repository = workspace?.repositories[0]; - if (!workspace || !repository) { - return undefined; - } - - if (repository.detail && !workspace.label.includes(`[${repository.detail}]`)) { - return repository.detail; - } - - if (!repository.workingDirectory) { - return undefined; - } - - const worktreeName = basename(repository.workingDirectory); - if (!worktreeName) { - return undefined; - } - - const repositoryName = basename(repository.uri); - if (worktreeName === workspace.label || worktreeName === repositoryName) { + if (!workspace || !repository || !repository.workingDirectory) { return undefined; } - return worktreeName; + return repository.branchName ?? repository.detail; } private _showContextMenu(e: MouseEvent): void { diff --git a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts index 95ab653c01044d..6550682d8724b0 100644 --- a/src/vs/workbench/api/browser/mainThreadCustomEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadCustomEditors.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { multibyteAwareBtoa } from '../../../base/common/strings.js'; -import { CancelablePromise, createCancelablePromise } from '../../../base/common/async.js'; +import { CancelablePromise, createCancelablePromise, DeferredPromise } from '../../../base/common/async.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { isCancellationError, onUnexpectedError } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IReference } from '../../../base/common/lifecycle.js'; @@ -26,6 +26,7 @@ import { MainThreadWebviewPanels } from './mainThreadWebviewPanels.js'; import { MainThreadWebviews, reviveWebviewExtension } from './mainThreadWebviews.js'; import * as extHostProtocol from '../common/extHost.protocol.js'; import { IRevertOptions, ISaveOptions } from '../../common/editor.js'; +import { CustomEditorDiffInput, CustomEditorSideBySideDiffInput } from '../../contrib/customEditor/browser/customEditorDiffInput.js'; import { CustomEditorInput } from '../../contrib/customEditor/browser/customEditorInput.js'; import { CustomDocumentBackupData } from '../../contrib/customEditor/browser/customEditorInputFactory.js'; import { ICustomEditorModel, ICustomEditorService } from '../../contrib/customEditor/common/customEditor.js'; @@ -33,7 +34,7 @@ import { CustomTextEditorModel } from '../../contrib/customEditor/common/customT import { ExtensionKeyedWebviewOriginStore, WebviewExtensionDescription } from '../../contrib/webview/browser/webview.js'; import { WebviewInput } from '../../contrib/webviewPanel/browser/webviewEditorInput.js'; import { IWebviewWorkbenchService } from '../../contrib/webviewPanel/browser/webviewWorkbenchService.js'; -import { editorGroupToColumn } from '../../services/editor/common/editorGroupColumn.js'; +import { EditorGroupColumn, editorGroupToColumn } from '../../services/editor/common/editorGroupColumn.js'; import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -51,6 +52,29 @@ const enum CustomEditorModelType { Text, } +type CustomEditorWebviewInput = CustomEditorInput | CustomEditorDiffInput | CustomEditorSideBySideDiffInput; + +interface CustomEditorDiffInitData { + readonly title: string; + readonly contentOptions: extHostProtocol.IWebviewContentOptions; + readonly options: extHostProtocol.IWebviewPanelOptions; + readonly active: boolean; +} + +interface CustomEditorSideBySideDiffData { + readonly handle: extHostProtocol.WebviewHandle; + readonly initData: CustomEditorDiffInitData; +} + +interface PendingCustomEditorSideBySideDiffResolution { + original?: CustomEditorSideBySideDiffData; + modified?: CustomEditorSideBySideDiffData; + started?: boolean; + readonly promise: DeferredPromise; + readonly cancellation: CancellationTokenSource; + readonly disposables: DisposableStore; +} + export class MainThreadCustomEditors extends Disposable implements extHostProtocol.MainThreadCustomEditorsShape { private readonly _proxyCustomEditors: extHostProtocol.ExtHostCustomEditorsShape; @@ -58,6 +82,7 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc private readonly _editorProviders = this._register(new DisposableMap()); private readonly _editorRenameBackups = new Map(); + private readonly _pendingSideBySideDiffResolutions = new Map(); private readonly _webviewOriginStore: ExtensionKeyedWebviewOriginStore; @@ -98,7 +123,7 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc // This reviver's only job is to activate custom editor extensions. this._register(_webviewWorkbenchService.registerResolver({ canResolve: (webview: WebviewInput) => { - if (webview instanceof CustomEditorInput) { + if (webview instanceof CustomEditorInput || webview instanceof CustomEditorDiffInput || webview instanceof CustomEditorSideBySideDiffInput) { extensionService.activateByEvent(`onCustomEditor:${webview.viewType}`); } return false; @@ -110,12 +135,28 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc this._register(workingCopyFileService.onWillRunWorkingCopyFileOperation(async e => this.onWillRunWorkingCopyFileOperation(e))); } - public $registerTextEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, capabilities: extHostProtocol.CustomTextEditorCapabilities, serializeBuffersForPostMessage: boolean): void { - this.registerEditorProvider(CustomEditorModelType.Text, reviveWebviewExtension(extensionData), viewType, options, capabilities, true, serializeBuffersForPostMessage); + public $registerTextEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, capabilities: extHostProtocol.CustomEditorProviderCapabilities, serializeBuffersForPostMessage: boolean): void { + this.registerEditorProvider( + CustomEditorModelType.Text, + reviveWebviewExtension(extensionData), + viewType, + options, + capabilities, + true, + serializeBuffersForPostMessage + ); } - public $registerCustomEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean): void { - this.registerEditorProvider(CustomEditorModelType.Custom, reviveWebviewExtension(extensionData), viewType, options, {}, supportsMultipleEditorsPerDocument, serializeBuffersForPostMessage); + public $registerCustomEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, capabilities: extHostProtocol.CustomEditorProviderCapabilities, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean): void { + this.registerEditorProvider( + CustomEditorModelType.Custom, + reviveWebviewExtension(extensionData), + viewType, + options, + capabilities, + supportsMultipleEditorsPerDocument, + serializeBuffersForPostMessage + ); } private registerEditorProvider( @@ -123,7 +164,7 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc extension: WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, - capabilities: extHostProtocol.CustomTextEditorCapabilities, + capabilities: extHostProtocol.CustomEditorProviderCapabilities, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean, ): void { @@ -134,16 +175,22 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc const disposables = new DisposableStore(); disposables.add(this._customEditorService.registerCustomEditorCapabilities(viewType, { - supportsMultipleEditorsPerDocument + supportsMultipleEditorsPerDocument, + isTextEditor: modelType === CustomEditorModelType.Text, + supportsInlineDiff: capabilities.supportsInlineDiff, + supportsSideBySideDiff: capabilities.supportsSideBySideDiff, })); disposables.add(this._webviewWorkbenchService.registerResolver({ canResolve: (webviewInput) => { - return webviewInput instanceof CustomEditorInput && webviewInput.viewType === viewType; + return (webviewInput instanceof CustomEditorInput || webviewInput instanceof CustomEditorDiffInput || webviewInput instanceof CustomEditorSideBySideDiffInput) && webviewInput.viewType === viewType; }, - resolveWebview: async (webviewInput: CustomEditorInput, cancellation: CancellationToken) => { + resolveWebview: async (webviewInput: WebviewInput, cancellation: CancellationToken) => { + if (!(webviewInput instanceof CustomEditorInput || webviewInput instanceof CustomEditorDiffInput || webviewInput instanceof CustomEditorSideBySideDiffInput)) { + return; + } + const handle = generateUuid(); - const resource = webviewInput.resource; webviewInput.webview.origin = this._webviewOriginStore.getOrigin(viewType, extension.id); @@ -151,45 +198,73 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc webviewInput.webview.options = options; webviewInput.webview.extension = extension; + const resource = webviewInput instanceof CustomEditorDiffInput ? webviewInput.modifiedResource : webviewInput.resource; + // If there's an old resource this was a move and we must resolve the backup at the same time as the webview // This is because the backup must be ready upon model creation, and the input resolve method comes after - let backupId = webviewInput.backupId; - if (webviewInput.oldResource && !webviewInput.backupId) { - const backup = this._editorRenameBackups.get(webviewInput.oldResource.toString()); - backupId = backup?.backupId; - this._editorRenameBackups.delete(webviewInput.oldResource.toString()); + let backupId: string | undefined; + if (webviewInput instanceof CustomEditorInput) { + backupId = webviewInput.backupId; + if (webviewInput.oldResource && !webviewInput.backupId) { + const backup = this._editorRenameBackups.get(webviewInput.oldResource.toString()); + backupId = backup?.backupId; + this._editorRenameBackups.delete(webviewInput.oldResource.toString()); + } } - let modelRef: IReference; + let modelRef: IReference | undefined; + const additionalModelRefs = new DisposableStore(); try { modelRef = await this.getOrCreateCustomEditorModel(modelType, resource, viewType, { backupId }, cancellation); + if (webviewInput instanceof CustomEditorDiffInput && !isEqual(webviewInput.originalResource, resource)) { + additionalModelRefs.add(await this.getOrCreateCustomEditorModel(modelType, webviewInput.originalResource, viewType, {}, cancellation)); + } else if (modelType === CustomEditorModelType.Text && webviewInput instanceof CustomEditorSideBySideDiffInput) { + const otherResource = webviewInput.side === 'original' ? webviewInput.modifiedResource : webviewInput.originalResource; + if (!isEqual(otherResource, resource)) { + additionalModelRefs.add(await this.getOrCreateCustomEditorModel(modelType, otherResource, viewType, {}, cancellation)); + } + } } catch (error) { onUnexpectedError(error); webviewInput.webview.setHtml(this.mainThreadWebview.getWebviewResolvedFailedContent(viewType)); + additionalModelRefs.dispose(); + modelRef?.dispose(); + return; + } + + if (!modelRef) { + additionalModelRefs.dispose(); return; } + let resolvedModelRef = modelRef; if (cancellation.isCancellationRequested) { - modelRef.dispose(); + additionalModelRefs.dispose(); + resolvedModelRef.dispose(); return; } - const disposeSub = webviewInput.webview.onDidDispose(() => { - disposeSub.dispose(); - inputDisposeSub.dispose(); + const disposeModelRefs = () => { + additionalModelRefs.dispose(); // If the model is still dirty, make sure we have time to save it - if (modelRef.object.isDirty()) { - const sub = modelRef.object.onDidChangeDirty(() => { - if (!modelRef.object.isDirty()) { + if (resolvedModelRef.object.isDirty()) { + const sub = resolvedModelRef.object.onDidChangeDirty(() => { + if (!resolvedModelRef.object.isDirty()) { sub.dispose(); - modelRef.dispose(); + resolvedModelRef.dispose(); } }); return; } - modelRef.dispose(); + resolvedModelRef.dispose(); + }; + + const disposeSub = webviewInput.webview.onDidDispose(() => { + disposeSub.dispose(); + inputDisposeSub.dispose(); + disposeModelRefs(); }); // Also listen for when the input is disposed (e.g., during SaveAs when the webview is transferred to a new editor). @@ -197,30 +272,50 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc const inputDisposeSub = webviewInput.onWillDispose(() => { inputDisposeSub.dispose(); disposeSub.dispose(); - modelRef.dispose(); + disposeModelRefs(); }); - if (capabilities.supportsMove) { + if (webviewInput instanceof CustomEditorInput && capabilities.supportsMove) { webviewInput.onMove(async (newResource: URI) => { - const oldModel = modelRef; - modelRef = await this.getOrCreateCustomEditorModel(modelType, newResource, viewType, {}, CancellationToken.None); + const oldModel = resolvedModelRef; + resolvedModelRef = await this.getOrCreateCustomEditorModel(modelType, newResource, viewType, {}, CancellationToken.None); this._proxyCustomEditors.$onMoveCustomEditor(handle, newResource, viewType); oldModel.dispose(); }); } try { - const actualResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(resource) : resource; - await this._proxyCustomEditors.$resolveCustomEditor(actualResource, handle, viewType, { + const initData = { title: webviewInput.getTitle(), contentOptions: webviewInput.webview.contentOptions, options: webviewInput.webview.options, active: webviewInput === this._editorService.activeEditor, - }, editorGroupToColumn(this._editorGroupService, webviewInput.group || 0), cancellation); + }; + const position = editorGroupToColumn(this._editorGroupService, webviewInput.group || 0); + + if (webviewInput instanceof CustomEditorDiffInput) { + const originalResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(webviewInput.originalResource) : webviewInput.originalResource; + const modifiedResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(webviewInput.modifiedResource) : webviewInput.modifiedResource; + await this._proxyCustomEditors.$resolveCustomEditorInlineDiff( + originalResource, + modifiedResource, + handle, + viewType, + initData, + position, + cancellation + ); + } else if (webviewInput instanceof CustomEditorSideBySideDiffInput) { + await this.resolveCustomEditorSideBySideDiff(modelType, webviewInput, handle, viewType, initData, position, cancellation); + } else { + const actualResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(resource) : resource; + await this._proxyCustomEditors.$resolveCustomEditor(actualResource, handle, viewType, initData, position, cancellation); + } } catch (error) { onUnexpectedError(error); webviewInput.webview.setHtml(this.mainThreadWebview.getWebviewResolvedFailedContent(viewType)); - modelRef.dispose(); + additionalModelRefs.dispose(); + resolvedModelRef.dispose(); return; } } @@ -229,6 +324,71 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc this._editorProviders.set(viewType, disposables); } + private resolveCustomEditorSideBySideDiff( + modelType: CustomEditorModelType, + webviewInput: CustomEditorSideBySideDiffInput, + handle: extHostProtocol.WebviewHandle, + viewType: string, + initData: CustomEditorDiffInitData, + position: EditorGroupColumn, + cancellation: CancellationToken, + ): Promise { + let pending = this._pendingSideBySideDiffResolutions.get(webviewInput.diffId); + if (!pending) { + pending = { + promise: new DeferredPromise(), + cancellation: new CancellationTokenSource(), + disposables: new DisposableStore(), + }; + this._pendingSideBySideDiffResolutions.set(webviewInput.diffId, pending); + } + + const cleanup = () => { + this._pendingSideBySideDiffResolutions.delete(webviewInput.diffId); + pending.disposables.dispose(); + pending.cancellation.dispose(); + }; + + pending.disposables.add(cancellation.onCancellationRequested(() => { + pending.cancellation.cancel(); + if (!pending.started) { + pending.promise.cancel(); + cleanup(); + } + })); + + pending[webviewInput.side] = { handle, initData }; + + if (pending.original && pending.modified && !pending.started) { + pending.started = true; + pending.promise.settleWith((async () => { + try { + const originalResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(webviewInput.originalResource) : webviewInput.originalResource; + const modifiedResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(webviewInput.modifiedResource) : webviewInput.modifiedResource; + await this._proxyCustomEditors.$resolveCustomEditorSideBySideDiff( + originalResource, + modifiedResource, + { + original: pending.original!.handle, + modified: pending.modified!.handle, + }, + viewType, + { + original: pending.original!.initData, + modified: pending.modified!.initData, + }, + position, + pending.cancellation.token + ); + } finally { + cleanup(); + } + })()); + } + + return pending.promise.p; + } + public $unregisterEditorProvider(viewType: string): void { if (!this._editorProviders.has(viewType)) { throw new Error(`No provider for ${viewType} registered`); @@ -261,7 +421,10 @@ export class MainThreadCustomEditors extends Disposable implements extHostProtoc { const model = MainThreadCustomEditorModel.create(this._instantiationService, this._proxyCustomEditors, viewType, resource, options, () => { return Array.from(this.mainThreadWebviewPanels.webviewInputs) - .filter(editor => editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) as CustomEditorInput[]; + .filter(editor => + (editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) + || (editor instanceof CustomEditorDiffInput && (isEqual(editor.originalResource, resource) || isEqual(editor.modifiedResource, resource))) + || (editor instanceof CustomEditorSideBySideDiffInput && isEqual(editor.resource, resource))) as CustomEditorWebviewInput[]; }, cancellation); return this._customEditorService.models.add(resource, viewType, model); } @@ -366,13 +529,14 @@ class MainThreadCustomEditorModel extends ResourceWorkingCopy implements ICustom viewType: string, resource: URI, options: { backupId?: string }, - getEditors: () => CustomEditorInput[], + getEditors: () => CustomEditorWebviewInput[], cancellation: CancellationToken, ): Promise { const editors = getEditors(); let untitledDocumentData: VSBuffer | undefined; - if (editors.length !== 0) { - untitledDocumentData = editors[0].untitledDocumentData; + const primaryCustomEditorInput = editors.find(editor => editor instanceof CustomEditorInput); + if (primaryCustomEditorInput) { + untitledDocumentData = primaryCustomEditorInput.untitledDocumentData; } const { editable } = await proxy.$createCustomDocument(resource, viewType, options.backupId, untitledDocumentData, cancellation); return instantiationService.createInstance(MainThreadCustomEditorModel, proxy, viewType, resource, !!options.backupId, editable, !!untitledDocumentData, getEditors); @@ -385,7 +549,7 @@ class MainThreadCustomEditorModel extends ResourceWorkingCopy implements ICustom fromBackup: boolean, private readonly _editable: boolean, startDirty: boolean, - private readonly _getEditors: () => CustomEditorInput[], + private readonly _getEditors: () => CustomEditorWebviewInput[], @IFileDialogService private readonly _fileDialogService: IFileDialogService, @IFileService fileService: IFileService, @ILabelService private readonly _labelService: ILabelService, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2d06f5a08e22bf..f9514d62fea433 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1020,8 +1020,27 @@ export interface IWebviewPanelOptions { readonly retainContextWhenHidden?: boolean; } -export interface CustomTextEditorCapabilities { +export interface CustomEditorProviderCapabilities { readonly supportsMove?: boolean; + readonly supportsInlineDiff?: boolean; + readonly supportsSideBySideDiff?: boolean; +} + +export interface CustomEditorDiffInitData { + readonly title: string; + readonly contentOptions: IWebviewContentOptions; + readonly options: IWebviewPanelOptions; + readonly active: boolean; +} + +export interface CustomEditorSideBySideDiffWebviewHandles { + readonly original: WebviewHandle; + readonly modified: WebviewHandle; +} + +export interface CustomEditorSideBySideDiffInitData { + readonly original: CustomEditorDiffInitData; + readonly modified: CustomEditorDiffInitData; } export const enum WebviewMessageArrayBufferViewType { @@ -1089,8 +1108,8 @@ export interface MainThreadWebviewPanelsShape extends IDisposable { } export interface MainThreadCustomEditorsShape extends IDisposable { - $registerTextEditorProvider(extension: WebviewExtensionDescription, viewType: string, options: IWebviewPanelOptions, capabilities: CustomTextEditorCapabilities, serializeBuffersForPostMessage: boolean): void; - $registerCustomEditorProvider(extension: WebviewExtensionDescription, viewType: string, options: IWebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean): void; + $registerTextEditorProvider(extension: WebviewExtensionDescription, viewType: string, options: IWebviewPanelOptions, capabilities: CustomEditorProviderCapabilities, serializeBuffersForPostMessage: boolean): void; + $registerCustomEditorProvider(extension: WebviewExtensionDescription, viewType: string, options: IWebviewPanelOptions, capabilities: CustomEditorProviderCapabilities, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean): void; $unregisterEditorProvider(viewType: string): void; $onDidEdit(resource: UriComponents, viewType: string, editId: number, label: string | undefined): void; @@ -1152,6 +1171,24 @@ export interface ExtHostCustomEditorsShape { position: EditorGroupColumn, cancellation: CancellationToken ): Promise; + $resolveCustomEditorInlineDiff( + originalResource: UriComponents, + modifiedResource: UriComponents, + newWebviewHandle: WebviewHandle, + viewType: string, + initData: CustomEditorDiffInitData, + position: EditorGroupColumn, + cancellation: CancellationToken + ): Promise; + $resolveCustomEditorSideBySideDiff( + originalResource: UriComponents, + modifiedResource: UriComponents, + webviewHandles: CustomEditorSideBySideDiffWebviewHandles, + viewType: string, + initData: CustomEditorSideBySideDiffInitData, + position: EditorGroupColumn, + cancellation: CancellationToken + ): Promise; $createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, untitledDocumentData: VSBuffer | undefined, cancellation: CancellationToken): Promise<{ editable: boolean }>; $disposeCustomDocument(resource: UriComponents, viewType: string): Promise; diff --git a/src/vs/workbench/api/common/extHostCustomEditors.ts b/src/vs/workbench/api/common/extHostCustomEditors.ts index e7ad5eb4e99e64..0fb101245645a9 100644 --- a/src/vs/workbench/api/common/extHostCustomEditors.ts +++ b/src/vs/workbench/api/common/extHostCustomEditors.ts @@ -21,6 +21,7 @@ import type * as vscode from 'vscode'; import { Cache } from './cache.js'; import * as extHostProtocol from './extHost.protocol.js'; import * as extHostTypes from './extHostTypes.js'; +import { isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; class CustomDocumentStoreEntry { @@ -184,9 +185,12 @@ export class ExtHostCustomEditors implements extHostProtocol.ExtHostCustomEditor disposables.add(this._editorProviders.addTextProvider(viewType, extension, provider)); this._proxy.$registerTextEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, { supportsMove: !!provider.moveCustomTextEditor, + supportsInlineDiff: isProposedApiEnabled(extension, 'customEditorDiffs') && isCustomTextEditorProviderWithInlineDiffCapability(provider), + supportsSideBySideDiff: isProposedApiEnabled(extension, 'customEditorDiffs') && isCustomTextEditorProviderWithSideBySideDiffCapability(provider), }, shouldSerializeBuffersForPostMessage(extension)); } else { disposables.add(this._editorProviders.addCustomProvider(viewType, extension, provider)); + const supportsCustomEditorDiffs = isProposedApiEnabled(extension, 'customEditorDiffs'); if (isCustomEditorProviderWithEditingCapability(provider)) { disposables.add(provider.onDidChangeCustomDocument(e => { @@ -200,7 +204,10 @@ export class ExtHostCustomEditors implements extHostProtocol.ExtHostCustomEditor })); } - this._proxy.$registerCustomEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, !!options.supportsMultipleEditorsPerDocument, shouldSerializeBuffersForPostMessage(extension)); + this._proxy.$registerCustomEditorProvider(toExtensionData(extension), viewType, options.webviewOptions || {}, { + supportsInlineDiff: supportsCustomEditorDiffs && isCustomEditorProviderWithInlineDiffCapability(provider), + supportsSideBySideDiff: supportsCustomEditorDiffs && isCustomEditorProviderWithSideBySideDiffCapability(provider), + }, !!options.supportsMultipleEditorsPerDocument, shouldSerializeBuffersForPostMessage(extension)); } return extHostTypes.Disposable.from( @@ -295,6 +302,89 @@ export class ExtHostCustomEditors implements extHostProtocol.ExtHostCustomEditor } } + async $resolveCustomEditorInlineDiff( + originalResource: UriComponents, + modifiedResource: UriComponents, + handle: extHostProtocol.WebviewHandle, + viewType: string, + initData: extHostProtocol.CustomEditorDiffInitData, + position: EditorGroupColumn, + cancellation: CancellationToken, + ): Promise { + const { entry, panel } = this.createCustomEditorDiffPanel(handle, viewType, initData, position); + const revivedOriginalResource = URI.revive(originalResource); + const revivedModifiedResource = URI.revive(modifiedResource); + + if (entry.type === CustomEditorType.Text) { + if (!isCustomTextEditorProviderWithInlineDiffCapability(entry.provider)) { + throw new Error(`Provider for '${viewType}' does not support inline custom text editor diffs`); + } + + const originalDocument = this._extHostDocuments.getDocument(revivedOriginalResource); + const modifiedDocument = this._extHostDocuments.getDocument(revivedModifiedResource); + return entry.provider.resolveCustomTextEditorInlineDiff({ original: originalDocument, modified: modifiedDocument }, panel, cancellation); + } + + if (!isCustomEditorProviderWithInlineDiffCapability(entry.provider)) { + throw new Error(`Provider for '${viewType}' does not support inline custom editor diffs`); + } + + const { document: originalDocument } = this.getCustomDocumentEntry(viewType, revivedOriginalResource); + const { document: modifiedDocument } = this.getCustomDocumentEntry(viewType, revivedModifiedResource); + return entry.provider.resolveCustomEditorInlineDiff({ original: originalDocument, modified: modifiedDocument }, panel, cancellation); + } + + async $resolveCustomEditorSideBySideDiff( + originalResource: UriComponents, + modifiedResource: UriComponents, + webviewHandles: extHostProtocol.CustomEditorSideBySideDiffWebviewHandles, + viewType: string, + initData: extHostProtocol.CustomEditorSideBySideDiffInitData, + position: EditorGroupColumn, + cancellation: CancellationToken, + ): Promise { + const { entry, panel: originalPanel } = this.createCustomEditorDiffPanel(webviewHandles.original, viewType, initData.original, position); + const { panel: modifiedPanel } = this.createCustomEditorDiffPanel(webviewHandles.modified, viewType, initData.modified, position); + const revivedOriginalResource = URI.revive(originalResource); + const revivedModifiedResource = URI.revive(modifiedResource); + + if (entry.type === CustomEditorType.Text) { + if (!isCustomTextEditorProviderWithSideBySideDiffCapability(entry.provider)) { + throw new Error(`Provider for '${viewType}' does not support side by side custom text editor diffs`); + } + + const originalDocument = this._extHostDocuments.getDocument(revivedOriginalResource); + const modifiedDocument = this._extHostDocuments.getDocument(revivedModifiedResource); + return entry.provider.resolveCustomTextEditorSideBySideDiff({ original: originalDocument, modified: modifiedDocument }, { original: originalPanel, modified: modifiedPanel }, cancellation); + } + + if (!isCustomEditorProviderWithSideBySideDiffCapability(entry.provider)) { + throw new Error(`Provider for '${viewType}' does not support side by side custom editor diffs`); + } + + const { document: originalDocument } = this.getCustomDocumentEntry(viewType, revivedOriginalResource); + const { document: modifiedDocument } = this.getCustomDocumentEntry(viewType, revivedModifiedResource); + return entry.provider.resolveCustomEditorSideBySideDiff({ original: originalDocument, modified: modifiedDocument }, { original: originalPanel, modified: modifiedPanel }, cancellation); + } + + private createCustomEditorDiffPanel( + handle: extHostProtocol.WebviewHandle, + viewType: string, + initData: extHostProtocol.CustomEditorDiffInitData, + position: EditorGroupColumn, + ): { entry: ProviderEntry; panel: vscode.WebviewPanel } { + const entry = this._editorProviders.get(viewType); + if (!entry) { + throw new Error(`No provider found for '${viewType}'`); + } + + const viewColumn = typeConverters.ViewColumn.to(position); + const webview = this._extHostWebview.createNewWebview(handle, initData.contentOptions, entry.extension); + this._extHostWebview.ensureDefaultContentOptions(handle, initData.contentOptions, entry.extension); + const panel = this._extHostWebviewPanels.createNewWebviewPanel(handle, viewType, initData.title, viewColumn, initData.options, webview, initData.active); + return { entry, panel }; + } + $disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void { const document = this.getCustomDocumentEntry(viewType, resourceComponents); document.disposeEdits(editIds); @@ -387,6 +477,22 @@ function isCustomTextEditorProvider(provider: vscode.CustomReadonlyEditorProvide return typeof (provider as vscode.CustomTextEditorProvider).resolveCustomTextEditor === 'function'; } +function isCustomTextEditorProviderWithInlineDiffCapability(provider: vscode.CustomTextEditorProvider): provider is vscode.CustomTextEditorProvider & Required> { + return typeof provider.resolveCustomTextEditorInlineDiff === 'function'; +} + +function isCustomTextEditorProviderWithSideBySideDiffCapability(provider: vscode.CustomTextEditorProvider): provider is vscode.CustomTextEditorProvider & Required> { + return typeof provider.resolveCustomTextEditorSideBySideDiff === 'function'; +} + +function isCustomEditorProviderWithInlineDiffCapability(provider: vscode.CustomReadonlyEditorProvider): provider is vscode.CustomReadonlyEditorProvider & Required> { + return typeof provider.resolveCustomEditorInlineDiff === 'function'; +} + +function isCustomEditorProviderWithSideBySideDiffCapability(provider: vscode.CustomReadonlyEditorProvider): provider is vscode.CustomReadonlyEditorProvider & Required> { + return typeof provider.resolveCustomEditorSideBySideDiff === 'function'; +} + function isEditEvent(e: vscode.CustomDocumentContentChangeEvent | vscode.CustomDocumentEditEvent): e is vscode.CustomDocumentEditEvent { return typeof (e as vscode.CustomDocumentEditEvent).undo === 'function' && typeof (e as vscode.CustomDocumentEditEvent).redo === 'function'; diff --git a/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts index 5c28b20e3956d9..1133dd6b624519 100644 --- a/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/diffEditorCommands.ts @@ -13,10 +13,10 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { TextDiffEditor } from './textDiffEditor.js'; -import { ActiveCompareEditorCanSwapContext, TextCompareEditorActiveContext, TextCompareEditorVisibleContext } from '../../../common/contextkeys.js'; +import { ActiveCompareEditorCanSwapContext, ActiveCustomEditorDiffCanToggleLayoutContext, TextCompareEditorActiveContext, TextCompareEditorVisibleContext } from '../../../common/contextkeys.js'; import { DiffEditorInput } from '../../../common/editor/diffEditorInput.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { IUntypedEditorInput } from '../../../common/editor.js'; +import { IUntypedEditorInput, isDiffEditorInput } from '../../../common/editor.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; @@ -109,6 +109,24 @@ export function registerDiffEditorCommands(): void { return undefined; } + function getActiveDiffModifiedResource(accessor: ServicesAccessor, args: unknown[]): URI | undefined { + const activeTextDiffEditor = getActiveTextDiffEditor(accessor, args); + const model = activeTextDiffEditor?.getControl()?.getModifiedEditor()?.getModel(); + if (model) { + return model.uri; + } + + const editorService = accessor.get(IEditorService); + const resource = args.length > 0 && args[0] instanceof URI ? args[0] : undefined; + for (const editor of [editorService.activeEditor, ...editorService.visibleEditors]) { + if (isDiffEditorInput(editor) && editor.modified.resource && (!resource || isEqual(editor.modified.resource, resource))) { + return editor.modified.resource; + } + } + + return undefined; + } + function navigateInDiffEditor(accessor: ServicesAccessor, args: unknown[], next: boolean): void { const activeTextDiffEditor = getActiveTextDiffEditor(accessor, args); @@ -146,14 +164,12 @@ export function registerDiffEditorCommands(): void { function toggleDiffSideBySide(accessor: ServicesAccessor, args: unknown[]): void { const configService = accessor.get(ITextResourceConfigurationService); - const activeTextDiffEditor = getActiveTextDiffEditor(accessor, args); - - const m = activeTextDiffEditor?.getControl()?.getModifiedEditor()?.getModel(); - if (!m) { return; } + const modifiedResource = getActiveDiffModifiedResource(accessor, args); + if (!modifiedResource) { return; } const key = 'diffEditor.renderSideBySide'; - const val = configService.getValue(m.uri, key); - configService.updateValue(m.uri, key, !val); + const val = configService.getValue(modifiedResource, key); + configService.updateValue(modifiedResource, key, !val); } function toggleDiffIgnoreTrimWhitespace(accessor: ServicesAccessor, args: unknown[]): void { @@ -269,7 +285,7 @@ export function registerDiffEditorCommands(): void { title: localize2('toggleInlineView', "Toggle Inline View"), category: localize('compare', "Compare") }, - when: TextCompareEditorActiveContext + when: ContextKeyExpr.or(TextCompareEditorActiveContext, ActiveCustomEditorDiffCanToggleLayoutContext) }); MenuRegistry.appendMenuItem(MenuId.CommandPalette, { diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 7d24435fc9ee58..b302484f3786cf 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -12,7 +12,7 @@ import { EditorPartMultipleEditorGroupsContext, ActiveEditorDirtyContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, EditorTabsVisibleContext, ActiveEditorLastInGroupContext, EditorPartMaximizedEditorGroupContext, MultipleEditorGroupsContext, InEditorZenModeContext, IsAuxiliaryWindowContext, ActiveCompareEditorCanSwapContext, MultipleEditorsSelectedInGroupContext, SplitEditorsVertically, - IsSessionsWindowContext + IsSessionsWindowContext, ActiveCustomEditorDiffCanToggleLayoutContext, ActiveCustomEditorTextDiffContext } from '../../../common/contextkeys.js'; import { SideBySideEditorInput, SideBySideEditorInputSerializer } from '../../../common/editor/sideBySideEditorInput.js'; import { TextResourceEditor } from './textResourceEditor.js'; @@ -420,7 +420,7 @@ MenuRegistry.appendMenuItem(MenuId.EditorSplitMoveSubmenu, { command: { id: SPLI MenuRegistry.appendMenuItem(MenuId.EditorSplitMoveSubmenu, { command: { id: JOIN_EDITOR_IN_GROUP, title: localize('joinInGroup', "Join in Group"), precondition: MultipleEditorsSelectedInGroupContext.negate() }, group: '3_split_in_group', order: 10, when: SideBySideEditorActiveContext }); // Editor Title Menu -MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, title: localize('inlineView', "Inline View"), toggled: ContextKeyExpr.equals('config.diffEditor.renderSideBySide', false) }, group: '1_diff', order: 10, when: ContextKeyExpr.has('isInDiffEditor') }); +MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_DIFF_SIDE_BY_SIDE, title: localize('inlineView', "Inline View"), toggled: ContextKeyExpr.equals('config.diffEditor.renderSideBySide', false) }, group: '1_diff', order: 10, when: ContextKeyExpr.or(ContextKeyExpr.has('isInDiffEditor'), ActiveCustomEditorDiffCanToggleLayoutContext) }); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: SHOW_EDITORS_IN_GROUP, title: localize('showOpenedEditors', "Show Opened Editors") }, group: '3_open', order: 10 }); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeAll', "Close All") }, group: '5_close', order: 10 }); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: CLOSE_SAVED_EDITORS_COMMAND_ID, title: localize('closeAllSaved', "Close Saved") }, group: '5_close', order: 20 }); @@ -638,6 +638,19 @@ appendEditorToolItem( undefined ); +// Custom Text Diff Editor Title Menu: Reopen as Text +appendEditorToolItem( + { + id: ReOpenInTextEditorAction.ID, + title: localize('reopenAsText', "Reopen as Text"), + icon: Codicon.fileCode + }, + ActiveCustomEditorTextDiffContext, + 16, + undefined, + undefined +); + const toggleWhitespace = registerIcon('diff-editor-toggle-whitespace', Codicon.whitespace, localize('toggleWhitespace', 'Icon for the toggle whitespace action in the diff editor.')); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index 43d92174c72c2a..d02d8fb7b125df 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -5,7 +5,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action } from '../../../../base/common/actions.js'; -import { IEditorIdentifier, IEditorCommandsContext, CloseDirection, SaveReason, EditorsOrder, EditorInputCapabilities, DEFAULT_EDITOR_ASSOCIATION, GroupIdentifier, EditorResourceAccessor } from '../../../common/editor.js'; +import { IEditorIdentifier, IEditorCommandsContext, CloseDirection, SaveReason, EditorsOrder, EditorInputCapabilities, DEFAULT_EDITOR_ASSOCIATION, GroupIdentifier, EditorResourceAccessor, isDiffEditorInput, isResourceDiffEditorInput } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; @@ -2543,11 +2543,13 @@ export class ToggleEditorTypeAction extends Action2 { } export class ReOpenInTextEditorAction extends Action2 { + static readonly ID = 'workbench.action.reopenTextEditor'; + static readonly TITLE = localize2('reopenTextEditor', 'Reopen Editor with Text Editor'); constructor() { super({ - id: 'workbench.action.reopenTextEditor', - title: localize2('reopenTextEditor', 'Reopen Editor with Text Editor'), + id: ReOpenInTextEditorAction.ID, + title: ReOpenInTextEditorAction.TITLE, f1: true, category: Categories.View, precondition: ActiveEditorAvailableEditorIdsContext @@ -2562,6 +2564,27 @@ export class ReOpenInTextEditorAction extends Action2 { return; } + if (isDiffEditorInput(activeEditorPane.input)) { + const untypedEditor = activeEditorPane.input.toUntyped(); + if (!isResourceDiffEditorInput(untypedEditor)) { + return; + } + + await editorService.replaceEditors([ + { + editor: activeEditorPane.input, + replacement: { + ...untypedEditor, + options: { + ...untypedEditor.options, + override: DEFAULT_EDITOR_ASSOCIATION.id + } + } + } + ], activeEditorPane.group); + return; + } + const activeEditorResource = EditorResourceAccessor.getCanonicalUri(activeEditorPane.input); if (!activeEditorResource) { return; diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 1e8e77248c10e2..e6a2e1984587b6 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -37,7 +37,10 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { IEditorResolverService } from '../../../services/editor/common/editorResolverService.js'; import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { IPathService } from '../../../services/path/common/pathService.js'; +import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; import { IUntitledTextEditorService } from '../../../services/untitled/common/untitledTextEditorService.js'; +import { IWorkingCopyEditorService } from '../../../services/workingCopy/common/workingCopyEditorService.js'; +import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; import { DIFF_FOCUS_OTHER_SIDE, DIFF_FOCUS_PRIMARY_SIDE, DIFF_FOCUS_SECONDARY_SIDE, registerDiffEditorCommands } from './diffEditorCommands.js'; import { IResolvedEditorCommandsContext, resolveCommandsContext } from './editorCommandsContext.js'; import { prepareMoveCopyEditors } from './editor.js'; @@ -950,6 +953,9 @@ function registerCloseEditorCommands() { const editorService = accessor.get(IEditorService); const editorResolverService = accessor.get(IEditorResolverService); const telemetryService = accessor.get(ITelemetryService); + const textFileService = accessor.get(ITextFileService); + const workingCopyService = accessor.get(IWorkingCopyService); + const workingCopyEditorService = accessor.get(IWorkingCopyEditorService); const resolvedContext = resolveCommandsContext(args, editorService, accessor.get(IEditorGroupsService), accessor.get(IListService)); const editorReplacements = new Map(); @@ -974,10 +980,39 @@ function registerCloseEditorCommands() { editorReplacements.set(group, editorReplacementsInGroup); } + // Force replace when closing the editor without saving cannot + // lose data. This is the case when the dirty state lives in a + // working copy whose lifetime is independent of the editor: + // `TextFileEditorModel`s and `UntitledTextEditorModel`s are + // kept alive while dirty by their owning service. + // + // This way switching between a text editor and a text-document + // based custom editor (such as the Markdown preview) for the + // same resource does not trigger a save dialog. + // + // Custom-document custom editors (e.g. hex editors) maintain + // their dirty state in a working copy whose lifetime is tied + // to the editor input, so we must not skip the save prompt + // for those — detect this by looking for any dirty working + // copy that backs this editor at a different resource. + const resource = editorToResolve.resource; + let forceReplaceDirty = !!resource && (resource.scheme === Schemas.untitled || textFileService.isDirty(resource)); + if (forceReplaceDirty && editorToResolve.isDirty()) { + for (const workingCopy of workingCopyService.dirtyWorkingCopies) { + if (isEqual(workingCopy.resource, resource)) { + continue; // working copy at the editor's own resource is text-based and survives close + } + if (workingCopyEditorService.findEditor(workingCopy)?.editor === editorToResolve) { + forceReplaceDirty = false; + break; + } + } + } + editorReplacementsInGroup.push({ editor: editor, replacement: resolvedEditor.editor, - forceReplaceDirty: editorToResolve.resource?.scheme === Schemas.untitled, + forceReplaceDirty, options: resolvedEditor.options }); diff --git a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts index 20a5b9d355a728..ff910dbaf0b88d 100644 --- a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts +++ b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts @@ -9,7 +9,7 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, IConfigurationNode, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; -import { IEditorResolverService, RegisteredEditorInfo, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; +import { diffEditorsAssociationsSettingId, editorsAssociationsSettingId, IEditorResolverService, RegisteredEditorInfo, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IJSONSchemaMap } from '../../../../base/common/jsonSchema.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { coalesce } from '../../../../base/common/arrays.js'; @@ -70,6 +70,7 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc private autoLockConfigurationNode: IConfigurationNode | undefined; private defaultBinaryEditorConfigurationNode: IConfigurationNode | undefined; private editorAssociationsConfigurationNode: IConfigurationNode | undefined; + private diffEditorAssociationsConfigurationNode: IConfigurationNode | undefined; private editorLargeFileConfirmationConfigurationNode: IConfigurationNode | undefined; constructor( @@ -152,7 +153,7 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc this.editorAssociationsConfigurationNode = { ...workbenchConfigurationNodeBase, properties: { - 'workbench.editorAssociations': { + [editorsAssociationsSettingId]: { type: 'object', markdownDescription: localize('editor.editorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors (for example `\"*.hex\": \"hexEditor.hexedit\"`). These have precedence over the default behavior."), patternProperties: { @@ -165,6 +166,24 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc } }; + // Registers setting for diffEditorAssociations + const oldDiffEditorAssociationsConfigurationNode = this.diffEditorAssociationsConfigurationNode; + this.diffEditorAssociationsConfigurationNode = { + ...workbenchConfigurationNodeBase, + properties: { + [diffEditorsAssociationsSettingId]: { + type: 'object', + markdownDescription: localize('editor.diffEditorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors for diff views (for example `\"*.md\": \"vscode.markdown.preview.editor\"`). These override `workbench.editorAssociations` for diffs."), + patternProperties: { + '.*': { + type: 'string', + enum: binaryEditorCandidates, + } + } + } + } + }; + // Registers setting for large file confirmation based on environment const oldEditorLargeFileConfirmationConfigurationNode = this.editorLargeFileConfirmationConfigurationNode; this.editorLargeFileConfirmationConfigurationNode = { @@ -185,12 +204,14 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc this.autoLockConfigurationNode, this.defaultBinaryEditorConfigurationNode, this.editorAssociationsConfigurationNode, + this.diffEditorAssociationsConfigurationNode, this.editorLargeFileConfirmationConfigurationNode ], remove: coalesce([ oldAutoLockConfigurationNode, oldDefaultBinaryEditorConfigurationNode, oldEditorAssociationsConfigurationNode, + oldDiffEditorAssociationsConfigurationNode, oldEditorLargeFileConfirmationConfigurationNode ]) }); diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 73e207b85d3c75..00f76894e85e8c 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -14,8 +14,7 @@ import { IModelService } from '../../editor/common/services/model.js'; import { Schemas } from '../../base/common/network.js'; import { EditorInput } from './editor/editorInput.js'; import { IEditorResolverService } from '../services/editor/common/editorResolverService.js'; -import { DEFAULT_EDITOR_ASSOCIATION } from './editor.js'; -import { DiffEditorInput } from './editor/diffEditorInput.js'; +import { DEFAULT_EDITOR_ASSOCIATION, isDiffEditorInput } from './editor.js'; //#region < --- Workbench --- > @@ -76,6 +75,8 @@ export const ActiveEditorAvailableEditorIdsContext = new RawContextKey(' export const TextCompareEditorVisibleContext = new RawContextKey('textCompareEditorVisible', false, localize('textCompareEditorVisible', "Whether a text compare editor is visible")); export const TextCompareEditorActiveContext = new RawContextKey('textCompareEditorActive', false, localize('textCompareEditorActive', "Whether a text compare editor is active")); export const SideBySideEditorActiveContext = new RawContextKey('sideBySideEditorActive', false, localize('sideBySideEditorActive', "Whether a side by side editor is active")); +export const ActiveCustomEditorDiffCanToggleLayoutContext = new RawContextKey('activeCustomEditorDiffCanToggleLayout', false, localize('activeCustomEditorDiffCanToggleLayout', "Whether the active custom editor diff can toggle between inline and side by side layout")); +export const ActiveCustomEditorTextDiffContext = new RawContextKey('activeCustomEditorTextDiff', false, localize('activeCustomEditorTextDiff', "Whether the active custom editor diff is backed by text documents")); // Editor Group Context Keys export const EditorGroupEditorsCountContext = new RawContextKey('groupEditorsCount', 0, localize('groupEditorsCount', "The number of opened editor groups")); @@ -344,7 +345,7 @@ function getAvailableEditorIds(editor: EditorInput, editorResolverService: IEdit // Diff editors. The original and modified resources of a diff editor // *should* be the same, but calculate the set intersection just to be safe. - if (editor instanceof DiffEditorInput) { + if (isDiffEditorInput(editor)) { const original = getAvailableEditorIds(editor.original, editorResolverService); const modified = new Set(getAvailableEditorIds(editor.modified, editorResolverService)); return original.filter(editor => modified.has(editor)); diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorDiffInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorDiffInput.ts new file mode 100644 index 00000000000000..5cb04d4e9a8277 --- /dev/null +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorDiffInput.ts @@ -0,0 +1,486 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../base/common/resources.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { IReference } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IUndoRedoService } from '../../../../platform/undoRedo/common/undoRedo.js'; +import { EditorInputCapabilities, GroupIdentifier, IDiffEditorInput, IResourceDiffEditorInput, IRevertOptions, ISaveOptions, IUntypedEditorInput, isEditorInput, isResourceEditorInput, isResourceDiffEditorInput, Verbosity } from '../../../common/editor.js'; +import { EditorInput, IUntypedEditorOptions } from '../../../common/editor/editorInput.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; +import { ITextEditorService } from '../../../services/textfile/common/textEditorService.js'; +import { ICustomEditorModel, ICustomEditorService } from '../common/customEditor.js'; +import { IOverlayWebview, IWebviewService } from '../../webview/browser/webview.js'; +import { IWebviewWorkbenchService, LazilyResolvedWebviewEditorInput } from '../../webviewPanel/browser/webviewWorkbenchService.js'; +import { WebviewIconPath } from '../../webviewPanel/browser/webviewEditorInput.js'; + +interface CustomEditorDiffInputInitInfo { + readonly originalResource: URI; + readonly modifiedResource: URI; + readonly viewType: string; + readonly label: string | undefined; + readonly description: string | undefined; + readonly iconPath: WebviewIconPath | undefined; +} + +interface CustomEditorSideBySideDiffInputInitInfo extends CustomEditorDiffInputInitInfo { + readonly diffId: string; + readonly side: CustomEditorSideBySideDiffSide; +} + +export type CustomEditorSideBySideDiffSide = 'original' | 'modified'; + +export class CustomEditorDiffInput extends LazilyResolvedWebviewEditorInput implements IDiffEditorInput { + + private _modelRef?: IReference; + + static create( + instantiationService: IInstantiationService, + init: CustomEditorDiffInputInitInfo, + group: IEditorGroup | undefined, + ): CustomEditorDiffInput { + return instantiationService.invokeFunction(accessor => { + const textEditorService = accessor.get(ITextEditorService); + const original = textEditorService.createTextEditor({ resource: init.originalResource }); + const modified = textEditorService.createTextEditor({ resource: init.modifiedResource }); + const webview = accessor.get(IWebviewService).createWebviewOverlay({ + providedViewType: init.viewType, + title: init.label, + options: {}, + contentOptions: {}, + extension: undefined, + }); + + const input = instantiationService.createInstance(CustomEditorDiffInput, init, original, modified, webview); + if (group) { + input.updateGroup(group.id); + } + + return input; + }); + } + + public static override readonly typeId = 'workbench.editors.customDiffEditor'; + + constructor( + private readonly init: CustomEditorDiffInputInitInfo, + readonly original: EditorInput, + readonly modified: EditorInput, + webview: IOverlayWebview, + @IThemeService themeService: IThemeService, + @IWebviewWorkbenchService webviewWorkbenchService: IWebviewWorkbenchService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ICustomEditorService private readonly customEditorService: ICustomEditorService, + @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IUndoRedoService private readonly undoRedoService: IUndoRedoService, + ) { + super({ providedId: init.viewType, viewType: init.viewType, name: init.label ?? '', iconPath: init.iconPath }, webview, themeService, webviewWorkbenchService); + this._register(this.filesConfigurationService.onDidChangeReadonly(() => this._onDidChangeCapabilities.fire())); + } + + override dispose(): void { + this.original.dispose(); + this.modified.dispose(); + super.dispose(); + } + + override get typeId(): string { + return CustomEditorDiffInput.typeId; + } + + override get editorId(): string { + return this.viewType; + } + + override get capabilities(): EditorInputCapabilities { + let capabilities = EditorInputCapabilities.Singleton | EditorInputCapabilities.CanDropIntoEditor; + if (this.isReadonly()) { + capabilities |= EditorInputCapabilities.Readonly; + } + return capabilities; + } + + override get resource(): URI { + return this.modifiedResource; + } + + get originalResource(): URI { + return this.init.originalResource; + } + + get modifiedResource(): URI { + return this.init.modifiedResource; + } + + override getName(): string { + return this.init.label ?? localize('customEditorDiffLabel', "{0} - {1}", this.original.getName(), this.modified.getName()); + } + + override getDescription(_verbosity?: Verbosity): string | undefined { + return this.init.description ?? super.getDescription(); + } + + override getTitle(verbosity?: Verbosity): string { + const description = this.getDescription(verbosity); + if (description) { + return localize('customEditorDiffTitle', "{0} ({1})", this.getName(), description); + } + + return this.getName(); + } + + override isReadonly(): boolean | IMarkdownString { + if (!this._modelRef) { + return this.filesConfigurationService.isReadonly(this.modifiedResource); + } + return this._modelRef.object.isReadonly(); + } + + override isDirty(): boolean { + return this._modelRef?.object.isDirty() ?? false; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + if (this === otherInput) { + return true; + } + + if (otherInput instanceof CustomEditorDiffInput) { + return this.viewType === otherInput.viewType + && isEqual(this.originalResource, otherInput.originalResource) + && isEqual(this.modifiedResource, otherInput.modifiedResource); + } + + if (isEditorInput(otherInput)) { + return false; + } + + if (isResourceDiffEditorInput(otherInput)) { + const override = otherInput.options?.override; + return override === this.viewType + && isEqual(this.originalResource, otherInput.original.resource) + && isEqual(this.modifiedResource, otherInput.modified.resource); + } + + return false; + } + + override copy(): EditorInput { + return CustomEditorDiffInput.create(this.instantiationService, this.init, undefined); + } + + override async save(groupId: GroupIdentifier, options?: ISaveOptions): Promise { + if (!this._modelRef) { + return undefined; + } + + const target = await this._modelRef.object.saveCustomEditor(options); + if (!target) { + return undefined; + } + + if (!isEqual(target, this.modifiedResource)) { + return this.toUntypedWithModifiedResource(target); + } + + return this; + } + + override async saveAs(groupId: GroupIdentifier, options?: ISaveOptions): Promise { + if (!this._modelRef) { + return undefined; + } + + const target = await this.fileDialogService.pickFileToSave(this.modifiedResource, options?.availableFileSystems); + if (!target) { + return undefined; + } + + if (!await this._modelRef.object.saveCustomEditorAs(this.modifiedResource, target, options)) { + return undefined; + } + + return this.toUntypedWithModifiedResource(target); + } + + override async revert(group: GroupIdentifier, options?: IRevertOptions): Promise { + await this._modelRef?.object.revert(options); + } + + override async resolve(): Promise { + await super.resolve(); + + if (this.isDisposed()) { + return null; + } + + if (!this._modelRef) { + const modelRef = this.customEditorService.models.tryRetain(this.modifiedResource, this.viewType); + if (modelRef) { + const oldCapabilities = this.capabilities; + this._modelRef = this._register(await modelRef); + this._register(this._modelRef.object.onDidChangeDirty(() => this._onDidChangeDirty.fire())); + this._register(this._modelRef.object.onDidChangeReadonly(() => this._onDidChangeCapabilities.fire())); + if (this.isDirty()) { + this._onDidChangeDirty.fire(); + } + if (this.capabilities !== oldCapabilities) { + this._onDidChangeCapabilities.fire(); + } + } + } + + return null; + } + + public undo(): void | Promise { + return this.undoRedoService.undo(this.modifiedResource); + } + + public redo(): void | Promise { + return this.undoRedoService.redo(this.modifiedResource); + } + + override toUntyped(_options?: IUntypedEditorOptions): IResourceDiffEditorInput { + return this.toUntypedWithModifiedResource(this.modifiedResource); + } + + private toUntypedWithModifiedResource(modifiedResource: URI): IResourceDiffEditorInput { + return { + original: { resource: this.originalResource }, + modified: { resource: modifiedResource }, + label: this.init.label, + description: this.init.description, + options: { + override: this.viewType, + } + }; + } +} + +export class CustomEditorSideBySideDiffInput extends LazilyResolvedWebviewEditorInput { + + private _modelRef?: IReference; + + static create( + instantiationService: IInstantiationService, + init: CustomEditorSideBySideDiffInputInitInfo, + group: IEditorGroup | undefined, + ): CustomEditorSideBySideDiffInput { + return instantiationService.invokeFunction(accessor => { + const textEditorService = accessor.get(ITextEditorService); + const sideInput = textEditorService.createTextEditor({ resource: init.side === 'original' ? init.originalResource : init.modifiedResource }); + const webview = accessor.get(IWebviewService).createWebviewOverlay({ + providedViewType: init.viewType, + title: sideInput.getName(), + options: {}, + contentOptions: {}, + extension: undefined, + }); + + const input = instantiationService.createInstance(CustomEditorSideBySideDiffInput, init, sideInput, webview); + if (group) { + input.updateGroup(group.id); + } + + return input; + }); + } + + public static override readonly typeId = 'workbench.editors.customSideBySideDiffEditor'; + + constructor( + private readonly init: CustomEditorSideBySideDiffInputInitInfo, + private readonly sideInput: EditorInput, + webview: IOverlayWebview, + @IThemeService themeService: IThemeService, + @IWebviewWorkbenchService webviewWorkbenchService: IWebviewWorkbenchService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ICustomEditorService private readonly customEditorService: ICustomEditorService, + @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IUndoRedoService private readonly undoRedoService: IUndoRedoService, + ) { + super({ providedId: init.viewType, viewType: init.viewType, name: sideInput.getName(), iconPath: init.iconPath }, webview, themeService, webviewWorkbenchService); + this._register(this.filesConfigurationService.onDidChangeReadonly(() => this._onDidChangeCapabilities.fire())); + } + + override dispose(): void { + this.sideInput.dispose(); + super.dispose(); + } + + override get typeId(): string { + return CustomEditorSideBySideDiffInput.typeId; + } + + override get editorId(): string { + return this.viewType; + } + + override get capabilities(): EditorInputCapabilities { + let capabilities = EditorInputCapabilities.Singleton | EditorInputCapabilities.CanDropIntoEditor; + if (this.isReadonly()) { + capabilities |= EditorInputCapabilities.Readonly; + } + return capabilities; + } + + override get resource(): URI { + return this.side === 'original' ? this.originalResource : this.modifiedResource; + } + + get originalResource(): URI { + return this.init.originalResource; + } + + get modifiedResource(): URI { + return this.init.modifiedResource; + } + + get side(): CustomEditorSideBySideDiffSide { + return this.init.side; + } + + get diffId(): string { + return this.init.diffId; + } + + override getName(): string { + return this.sideInput.getName(); + } + + override getDescription(verbosity?: Verbosity): string | undefined { + return this.sideInput.getDescription(verbosity); + } + + override getTitle(verbosity?: Verbosity): string { + return this.sideInput.getTitle(verbosity); + } + + override isReadonly(): boolean | IMarkdownString { + if (this.side === 'original') { + return true; + } + if (!this._modelRef) { + return this.filesConfigurationService.isReadonly(this.modifiedResource); + } + return this._modelRef.object.isReadonly(); + } + + override isDirty(): boolean { + return this.side === 'modified' ? this._modelRef?.object.isDirty() ?? false : false; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + if (this === otherInput) { + return true; + } + + if (otherInput instanceof CustomEditorSideBySideDiffInput) { + return this.editorId === otherInput.editorId + && this.side === otherInput.side + && isEqual(this.originalResource, otherInput.originalResource) + && isEqual(this.modifiedResource, otherInput.modifiedResource); + } + + if (isEditorInput(otherInput)) { + return false; + } + + if (isResourceEditorInput(otherInput)) { + return isEqual(this.resource, otherInput.resource); + } + + return false; + } + + override copy(): EditorInput { + return CustomEditorSideBySideDiffInput.create(this.instantiationService, this.init, undefined); + } + + override async save(groupId: GroupIdentifier, options?: ISaveOptions): Promise { + if (!this._modelRef) { + return undefined; + } + + const target = await this._modelRef.object.saveCustomEditor(options); + if (!target) { + return undefined; + } + + if (!isEqual(target, this.modifiedResource)) { + return { resource: target }; + } + + return this; + } + + override async saveAs(groupId: GroupIdentifier, options?: ISaveOptions): Promise { + if (!this._modelRef) { + return undefined; + } + + const target = await this.fileDialogService.pickFileToSave(this.modifiedResource, options?.availableFileSystems); + if (!target) { + return undefined; + } + + if (!await this._modelRef.object.saveCustomEditorAs(this.modifiedResource, target, options)) { + return undefined; + } + + return { resource: target }; + } + + override async revert(group: GroupIdentifier, options?: IRevertOptions): Promise { + await this._modelRef?.object.revert(options); + } + + override async resolve(): Promise { + await super.resolve(); + + if (this.isDisposed()) { + return null; + } + + if (this.side === 'modified' && !this._modelRef) { + const modelRef = this.customEditorService.models.tryRetain(this.modifiedResource, this.viewType); + if (modelRef) { + const oldCapabilities = this.capabilities; + this._modelRef = this._register(await modelRef); + this._register(this._modelRef.object.onDidChangeDirty(() => this._onDidChangeDirty.fire())); + this._register(this._modelRef.object.onDidChangeReadonly(() => this._onDidChangeCapabilities.fire())); + if (this.isDirty()) { + this._onDidChangeDirty.fire(); + } + if (this.capabilities !== oldCapabilities) { + this._onDidChangeCapabilities.fire(); + } + } + } + + return null; + } + + public undo(): void | Promise { + return this.undoRedoService.undo(this.modifiedResource); + } + + public redo(): void | Promise { + return this.undoRedoService.redo(this.modifiedResource); + } + + override toUntyped(_options?: IUntypedEditorOptions): IUntypedEditorInput { + return { resource: this.resource }; + } +} diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts index 62b3f0a2305a9e..d9106e60e11f44 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditors.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditors.ts @@ -11,7 +11,9 @@ import { Schemas } from '../../../../base/common/network.js'; import { extname, isEqual } from '../../../../base/common/resources.js'; import { assertReturnsDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { RedoCommand, UndoCommand } from '../../../../editor/browser/editorExtensions.js'; +import { ITextResourceConfigurationChangeEvent, ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js'; import { IResourceEditorInput } from '../../../../platform/editor/common/editor.js'; import { FileOperation, IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -21,14 +23,26 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { DEFAULT_EDITOR_ASSOCIATION, EditorExtensions, GroupIdentifier, IEditorFactoryRegistry, IResourceDiffEditorInput } from '../../../common/editor.js'; import { DiffEditorInput } from '../../../common/editor/diffEditorInput.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { CONTEXT_ACTIVE_CUSTOM_EDITOR_ID, CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE, CustomEditorCapabilities, CustomEditorInfo, CustomEditorInfoCollection, ICustomEditorModelManager, ICustomEditorService } from '../common/customEditor.js'; +import { ActiveCustomEditorDiffCanToggleLayoutContext, ActiveCustomEditorTextDiffContext } from '../../../common/contextkeys.js'; +import { CONTEXT_ACTIVE_CUSTOM_EDITOR_ID, CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE, CustomEditorCapabilities, CustomEditorDiffEditorLayout, CustomEditorInfo, CustomEditorInfoCollection, ICustomEditorModelManager, ICustomEditorService } from '../common/customEditor.js'; import { CustomEditorModelManager } from '../common/customEditorModelManager.js'; import { IEditorGroup, IEditorGroupContextKeyProvider, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorResolverService, IEditorType, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorService, IUntypedEditorReplacement } from '../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { ContributedCustomEditors } from '../common/contributedCustomEditors.js'; +import { CustomEditorDiffInput, CustomEditorSideBySideDiffInput } from './customEditorDiffInput.js'; import { CustomEditorInput } from './customEditorInput.js'; +interface CustomEditorDiffInputInfo { + readonly viewType: string; + readonly originalResource: URI; + readonly modifiedResource: URI; + readonly layout: CustomEditorDiffEditorLayout; +} + +type CustomEditorUndoRedoInput = CustomEditorInput | CustomEditorDiffInput | CustomEditorSideBySideDiffInput; + export class CustomEditorService extends Disposable implements ICustomEditorService { _serviceBrand: any; @@ -52,6 +66,8 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ @IInstantiationService private readonly instantiationService: IInstantiationService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IEditorResolverService private readonly editorResolverService: IEditorResolverService, + @ITextResourceConfigurationService private readonly textResourceConfigurationService: ITextResourceConfigurationService, + @IExtensionService private readonly extensionService: IExtensionService, ) { super(); @@ -81,8 +97,26 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ onDidChange: this.onDidChangeEditorTypes }; + const customEditorDiffCanToggleLayoutContextKeyProvider: IEditorGroupContextKeyProvider = { + contextKey: ActiveCustomEditorDiffCanToggleLayoutContext, + getGroupContextKeyValue: group => this.getActiveCustomEditorDiffCanToggleLayout(group), + onDidChange: this.onDidChangeEditorTypes + }; + + const customEditorTextDiffContextKeyProvider: IEditorGroupContextKeyProvider = { + contextKey: ActiveCustomEditorTextDiffContext, + getGroupContextKeyValue: group => this.getActiveCustomEditorTextDiff(group), + onDidChange: this.onDidChangeEditorTypes + }; + this._register(this.editorGroupService.registerContextKeyProvider(activeCustomEditorContextKeyProvider)); this._register(this.editorGroupService.registerContextKeyProvider(customEditorIsEditableContextKeyProvider)); + this._register(this.editorGroupService.registerContextKeyProvider(customEditorDiffCanToggleLayoutContextKeyProvider)); + this._register(this.editorGroupService.registerContextKeyProvider(customEditorTextDiffContextKeyProvider)); + + this._register(this.textResourceConfigurationService.onDidChangeConfiguration(e => { + void this.updateCustomDiffEditorsForDiffConfigurationChange(e); + })); this._register(fileService.onDidRunOperation(e => { if (e.isOperation(FileOperation.MOVE)) { @@ -103,10 +137,10 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ return [...this._contributedEditors]; } - private withActiveCustomEditor(f: (editor: CustomEditorInput) => void | Promise): boolean | Promise { - const activeEditor = this.editorService.activeEditor; - if (activeEditor instanceof CustomEditorInput) { - const result = f(activeEditor); + private withActiveCustomEditor(f: (editor: CustomEditorUndoRedoInput) => void | Promise): boolean | Promise { + const editor = this.getActiveCustomEditorUndoRedoInput(); + if (editor) { + const result = f(editor); if (result) { return result; } @@ -115,6 +149,17 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ return false; } + private getActiveCustomEditorUndoRedoInput(): CustomEditorUndoRedoInput | undefined { + const activeEditor = this.editorService.activeEditor; + if (activeEditor instanceof CustomEditorInput || activeEditor instanceof CustomEditorDiffInput || activeEditor instanceof CustomEditorSideBySideDiffInput) { + return activeEditor; + } + if (activeEditor instanceof DiffEditorInput && activeEditor.modified instanceof CustomEditorSideBySideDiffInput) { + return activeEditor.modified; + } + return undefined; + } + private registerContributionPoints(): void { // Clear all previous contributions we know this._editorResolverDisposables.clear(); @@ -143,8 +188,9 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ createUntitledEditorInput: ({ resource }, group) => { return { editor: CustomEditorInput.create(this.instantiationService, { resource: resource ?? URI.from({ scheme: Schemas.untitled, authority: `Untitled-${this._untitledCounter++}` }), viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id) }; }, - createDiffEditorInput: (diffEditorInput, group) => { - return { editor: this.createDiffEditorInput(diffEditorInput, contributedEditor.id, group) }; + createDiffEditorInput: async (diffEditorInput, group) => { + await this.extensionService.activateByEvent(`onCustomEditor:${contributedEditor.id}`); + return { editor: this.createDiffEditorInput(diffEditorInput, contributedEditor, group) }; }, } )); @@ -154,14 +200,132 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ private createDiffEditorInput( editor: IResourceDiffEditorInput, - editorID: string, - group: IEditorGroup - ): DiffEditorInput { - const modifiedOverride = CustomEditorInput.create(this.instantiationService, { resource: assertReturnsDefined(editor.modified.resource), viewType: editorID, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'modified' }); - const originalOverride = CustomEditorInput.create(this.instantiationService, { resource: assertReturnsDefined(editor.original.resource), viewType: editorID, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'original' }); + contributedEditor: CustomEditorInfo, + group: IEditorGroup, + ): EditorInput { + const originalResource = assertReturnsDefined(editor.original.resource); + const modifiedResource = assertReturnsDefined(editor.modified.resource); + const diffEditorLayout = this.getDiffEditorLayout(contributedEditor, modifiedResource); + + if (diffEditorLayout === CustomEditorDiffEditorLayout.Inline) { + return CustomEditorDiffInput.create(this.instantiationService, { + originalResource, + modifiedResource, + viewType: contributedEditor.id, + label: editor.label, + description: editor.description, + iconPath: undefined + }, group); + } + + if (diffEditorLayout === CustomEditorDiffEditorLayout.SideBySide) { + const diffId = generateUuid(); + const originalOverride = CustomEditorSideBySideDiffInput.create(this.instantiationService, { + originalResource, + modifiedResource, + viewType: contributedEditor.id, + diffId, + side: 'original', + label: editor.label, + description: editor.description, + iconPath: undefined + }, group); + const modifiedOverride = CustomEditorSideBySideDiffInput.create(this.instantiationService, { + originalResource, + modifiedResource, + viewType: contributedEditor.id, + diffId, + side: 'modified', + label: editor.label, + description: editor.description, + iconPath: undefined + }, group); + return this.instantiationService.createInstance(DiffEditorInput, editor.label, editor.description, originalOverride, modifiedOverride, true); + } + + const modifiedOverride = CustomEditorInput.create(this.instantiationService, { resource: modifiedResource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'modified' }); + const originalOverride = CustomEditorInput.create(this.instantiationService, { resource: originalResource, viewType: contributedEditor.id, webviewTitle: undefined, iconPath: undefined }, group.id, { customClasses: 'original' }); return this.instantiationService.createInstance(DiffEditorInput, editor.label, editor.description, originalOverride, modifiedOverride, true); } + private getDiffEditorLayout(contributedEditor: CustomEditorInfo, modifiedResource: URI): CustomEditorDiffEditorLayout | undefined { + const capabilities = this.getCustomEditorCapabilities(contributedEditor.id); + const supportsInlineDiff = capabilities?.supportsInlineDiff === true; + const supportsSideBySideDiff = capabilities?.supportsSideBySideDiff === true; + + if (supportsInlineDiff && supportsSideBySideDiff) { + return this.textResourceConfigurationService.getValue(modifiedResource, 'diffEditor.renderSideBySide') ? CustomEditorDiffEditorLayout.SideBySide : CustomEditorDiffEditorLayout.Inline; + } + + return supportsInlineDiff ? CustomEditorDiffEditorLayout.Inline : supportsSideBySideDiff ? CustomEditorDiffEditorLayout.SideBySide : undefined; + } + + private async updateCustomDiffEditorsForDiffConfigurationChange(e: ITextResourceConfigurationChangeEvent): Promise { + for (const group of this.editorGroupService.groups) { + const replacements: IUntypedEditorReplacement[] = []; + for (const editor of group.editors) { + const diffInfo = this.getCustomEditorDiffInputInfo(editor); + const contributedEditor = diffInfo ? this._contributedEditors.get(diffInfo.viewType) : undefined; + if (!diffInfo + || !contributedEditor + || !e.affectsConfiguration(diffInfo.modifiedResource, 'diffEditor.renderSideBySide') + || !this.getCustomEditorCapabilities(contributedEditor.id)?.supportsInlineDiff + || !this.getCustomEditorCapabilities(contributedEditor.id)?.supportsSideBySideDiff + || this.getDiffEditorLayout(contributedEditor, diffInfo.modifiedResource) === diffInfo.layout) { + continue; + } + + replacements.push({ + editor, + replacement: { + original: { resource: diffInfo.originalResource }, + modified: { resource: diffInfo.modifiedResource }, + label: editor.getName(), + description: editor.getDescription(), + options: { + override: diffInfo.viewType, + pinned: group.isPinned(editor), + sticky: group.isSticky(editor), + preserveFocus: group.activeEditor !== editor, + } + } + }); + } + + if (replacements.length) { + await this.editorService.replaceEditors(replacements, group); + } + } + } + + private getCustomEditorDiffInputInfo(input: EditorInput | undefined): CustomEditorDiffInputInfo | undefined { + if (input instanceof CustomEditorDiffInput) { + return { + viewType: input.viewType, + originalResource: input.originalResource, + modifiedResource: input.modifiedResource, + layout: CustomEditorDiffEditorLayout.Inline, + }; + } + + if (input instanceof DiffEditorInput + && input.original instanceof CustomEditorSideBySideDiffInput + && input.modified instanceof CustomEditorSideBySideDiffInput + && input.original.side === 'original' + && input.modified.side === 'modified' + && input.original.viewType === input.modified.viewType + && input.original.diffId === input.modified.diffId) { + return { + viewType: input.original.viewType, + originalResource: input.original.originalResource, + modifiedResource: input.original.modifiedResource, + layout: CustomEditorDiffEditorLayout.SideBySide, + }; + } + + return undefined; + } + public get models() { return this._models; } public getCustomEditor(viewType: string): CustomEditorInfo | undefined { @@ -191,8 +355,10 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ throw new Error(`Capabilities for ${viewType} already set`); } this._editorCapabilities.set(viewType, options); + this._onDidChangeEditorTypes.fire(); return toDisposable(() => { this._editorCapabilities.delete(viewType); + this._onDidChangeEditorTypes.fire(); }); } @@ -202,12 +368,24 @@ export class CustomEditorService extends Disposable implements ICustomEditorServ private getActiveCustomEditorId(group: IEditorGroup): string { const activeEditorPane = group.activeEditorPane; - const resource = activeEditorPane?.input?.resource; - if (!resource) { - return ''; + const input = activeEditorPane?.input; + const diffInfo = this.getCustomEditorDiffInputInfo(input); + if (diffInfo) { + return diffInfo.viewType; } - return activeEditorPane?.input instanceof CustomEditorInput ? activeEditorPane.input.viewType : ''; + return input instanceof CustomEditorInput && input.resource ? input.viewType : ''; + } + + private getActiveCustomEditorDiffCanToggleLayout(group: IEditorGroup): boolean { + const diffInfo = this.getCustomEditorDiffInputInfo(group.activeEditorPane?.input); + const capabilities = diffInfo ? this.getCustomEditorCapabilities(diffInfo.viewType) : undefined; + return capabilities?.supportsInlineDiff === true && capabilities.supportsSideBySideDiff === true; + } + + private getActiveCustomEditorTextDiff(group: IEditorGroup): boolean { + const diffInfo = this.getCustomEditorDiffInputInfo(group.activeEditorPane?.input); + return !!diffInfo && this.getCustomEditorCapabilities(diffInfo.viewType)?.isTextEditor === true; } private getCustomEditorIsEditable(group: IEditorGroup): boolean { diff --git a/src/vs/workbench/contrib/customEditor/common/customEditor.ts b/src/vs/workbench/contrib/customEditor/common/customEditor.ts index 7ea444c107fcbc..474cb684507cfe 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditor.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditor.ts @@ -25,6 +25,9 @@ export const CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE = new RawContextKey } | undefined; + +export function registerUpdateTitleBarMenuPlacement(menuId: MenuId, item: Omit = {}): void { + if (additionalMenuPlacement) { + throw new Error('An additional update title bar menu placement is already registered'); + } + additionalMenuPlacement = { menuId, item }; +} + registerAction2(class UpdateIndicatorTitleBarAction extends Action2 { constructor() { super({ @@ -103,23 +116,42 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench this._register(actionViewItemService.register( MenuId.TitleBarAdjacentCenter, UPDATE_TITLE_BAR_ACTION_ID, - (action, options) => { - this.entry = instantiationService.createInstance(UpdateTitleBarEntry, action, options, this.tooltip, () => { - this.tooltipVisible = false; - if (!ACTIONABLE_STATES.includes(this.state.type) && !DETAILED_STATES.includes(this.state.type)) { - this.context.set(false); - } - }); - if (this.tooltipVisible) { - this.entry.showTooltip(); - } - return this.entry; - } + (action, options) => this.createEntry(instantiationService, action, options) )); + if (additionalMenuPlacement) { + const { menuId, item } = additionalMenuPlacement; + MenuRegistry.appendMenuItem(menuId, { + ...item, + command: { + id: UPDATE_TITLE_BAR_ACTION_ID, + title: localize('updateIndicatorTitleBarAction', 'Update'), + }, + when: item.when ? ContextKeyExpr.and(UPDATE_TITLE_BAR_CONTEXT, item.when) : UPDATE_TITLE_BAR_CONTEXT, + }); + this._register(actionViewItemService.register( + menuId, + UPDATE_TITLE_BAR_ACTION_ID, + (action, options) => this.createEntry(instantiationService, action, options) + )); + } + void this.onStateChange(true); } + private createEntry(instantiationService: IInstantiationService, action: IAction, options: IBaseActionViewItemOptions): UpdateTitleBarEntry { + this.entry = instantiationService.createInstance(UpdateTitleBarEntry, action, options, this.tooltip, () => { + this.tooltipVisible = false; + if (!ACTIONABLE_STATES.includes(this.state.type) && !DETAILED_STATES.includes(this.state.type)) { + this.context.set(false); + } + }); + if (this.tooltipVisible) { + this.entry.showTooltip(); + } + return this.entry; + } + private async onStateChange(startup = false) { this.pendingShow.clear(); diff --git a/src/vs/workbench/services/editor/browser/editorResolverService.ts b/src/vs/workbench/services/editor/browser/editorResolverService.ts index a2e175c52af9ed..c00d15a9a2819e 100644 --- a/src/vs/workbench/services/editor/browser/editorResolverService.ts +++ b/src/vs/workbench/services/editor/browser/editorResolverService.ts @@ -14,7 +14,7 @@ import { DEFAULT_EDITOR_ASSOCIATION, EditorResourceAccessor, EditorInputWithOpti import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorGroup, IEditorGroupsService } from '../common/editorGroupsService.js'; import { Schemas } from '../../../../base/common/network.js'; -import { RegisteredEditorInfo, RegisteredEditorPriority, RegisteredEditorOptions, EditorAssociation, EditorAssociations, editorsAssociationsSettingId, globMatchesResource, IEditorResolverService, priorityToRank, ResolvedEditor, ResolvedStatus, EditorInputFactoryObject } from '../common/editorResolverService.js'; +import { RegisteredEditorInfo, RegisteredEditorPriority, RegisteredEditorOptions, EditorAssociation, EditorAssociations, diffEditorsAssociationsSettingId, editorsAssociationsSettingId, globMatchesResource, IEditorResolverService, priorityToRank, ResolvedEditor, ResolvedStatus, EditorInputFactoryObject } from '../common/editorResolverService.js'; import { QuickPickItem, IKeyMods, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../nls.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; @@ -37,6 +37,11 @@ interface RegisteredEditor { type RegisteredEditors = Array; +const enum EditorAssociationType { + Editor, + DiffEditor +} + export class EditorResolverService extends Disposable implements IEditorResolverService { readonly _serviceBrand: undefined; @@ -125,7 +130,8 @@ export class EditorResolverService extends Disposable implements IEditorResolver let resource = EditorResourceAccessor.getCanonicalUri(untypedEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); // If it was resolved before we await for the extensions to activate and then proceed with resolution or else the backing extensions won't be registered - if (this.cache && resource && (this.resourceMatchesCache(resource) || this.resourceMatchesUserAssociation(resource))) { + const editorAssociationType = isResourceDiffEditorInput(untypedEditor) ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + if (this.cache && resource && (this.resourceMatchesCache(resource) || this.resourceMatchesUserAssociation(resource, editorAssociationType))) { await this.extensionService.whenInstalledExtensionsRegistered(); } @@ -147,13 +153,13 @@ export class EditorResolverService extends Disposable implements IEditorResolver } // Resolved the editor ID as much as possible, now find a given editor (cast here is ok because we resolve down to a string above) - let { editor: selectedEditor, conflictingDefault } = this.getEditor(resource, untypedEditor.options?.override as (string | EditorResolution.EXCLUSIVE_ONLY | undefined)); + let { editor: selectedEditor, conflictingDefault } = this.getEditor(resource, untypedEditor.options?.override as (string | EditorResolution.EXCLUSIVE_ONLY | undefined), editorAssociationType); // If no editor was found and this was a typed editor or an editor with an explicit override we could not resolve it if (!selectedEditor && (untypedEditor.options?.override || isEditorInputWithOptions(editor))) { return ResolvedStatus.NONE; } else if (!selectedEditor) { // Simple untyped editors that we could not resolve will be resolved to the default editor - const resolvedEditor = this.getEditor(resource, DEFAULT_EDITOR_ASSOCIATION.id); + const resolvedEditor = this.getEditor(resource, DEFAULT_EDITOR_ASSOCIATION.id, editorAssociationType); selectedEditor = resolvedEditor?.editor; conflictingDefault = resolvedEditor?.conflictingDefault; if (!selectedEditor) { @@ -167,9 +173,9 @@ export class EditorResolverService extends Disposable implements IEditorResolver if (!resource2) { resource2 = URI.from({ scheme: Schemas.untitled }); } - const { editor: selectedEditor2 } = this.getEditor(resource2, undefined); + const { editor: selectedEditor2 } = this.getEditor(resource2, undefined, editorAssociationType); if (!selectedEditor2 || selectedEditor.editorInfo.id !== selectedEditor2.editorInfo.id) { - const { editor: selectedDiff, conflictingDefault: conflictingDefaultDiff } = this.getEditor(resource, DEFAULT_EDITOR_ASSOCIATION.id); + const { editor: selectedDiff, conflictingDefault: conflictingDefaultDiff } = this.getEditor(resource, DEFAULT_EDITOR_ASSOCIATION.id, editorAssociationType); selectedEditor = selectedDiff; conflictingDefault = conflictingDefaultDiff; } @@ -262,17 +268,47 @@ export class EditorResolverService extends Disposable implements IEditorResolver } getAssociationsForResource(resource: URI): EditorAssociations { - const associations = this.getAllUserAssociations(); - let matchingAssociations = associations.filter(association => association.filenamePattern && globMatchesResource(association.filenamePattern, resource)); - // Sort matching associations based on glob length as a longer glob will be more specific - matchingAssociations = matchingAssociations.sort((a, b) => (b.filenamePattern?.length ?? 0) - (a.filenamePattern?.length ?? 0)); + return this.getAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId); + } + + private getAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { + if (associationType === EditorAssociationType.Editor) { + return this.getAssociationsForResource(resource); + } + + const diffAssociations = this.getAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId); + return diffAssociations.length ? diffAssociations : this.getAssociationsForResource(resource); + } + + private getAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { + const matchingAssociations = this.getRawAssociationsForResourceFromSetting(resource, settingId); const allEditors: RegisteredEditors = this._registeredEditors; // Ensure that the settings are valid editors return matchingAssociations.filter(association => allEditors.find(c => c.editorInfo.id === association.viewType)); } + private getRawAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { + if (associationType === EditorAssociationType.Editor) { + return this.getRawAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId); + } + + const diffAssociations = this.getRawAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId); + return diffAssociations.length ? diffAssociations : this.getRawAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId); + } + + private getRawAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { + const associations = this.getAllUserAssociationsForSetting(settingId); + const matchingAssociations = associations.filter(association => association.filenamePattern && globMatchesResource(association.filenamePattern, resource)); + // Sort matching associations based on glob length as a longer glob will be more specific + return matchingAssociations.sort((a, b) => (b.filenamePattern?.length ?? 0) - (a.filenamePattern?.length ?? 0)); + } + getAllUserAssociations(): EditorAssociations { - const inspectedEditorAssociations = this.configurationService.inspect<{ [fileNamePattern: string]: string }>(editorsAssociationsSettingId) || {}; + return this.getAllUserAssociationsForSetting(editorsAssociationsSettingId); + } + + private getAllUserAssociationsForSetting(settingId: string): EditorAssociations { + const inspectedEditorAssociations = this.configurationService.inspect<{ [fileNamePattern: string]: string }>(settingId) || {}; const defaultAssociations = inspectedEditorAssociations.defaultValue ?? {}; const workspaceAssociations = inspectedEditorAssociations.workspaceValue ?? {}; const userAssociations = inspectedEditorAssociations.userValue ?? {}; @@ -340,8 +376,16 @@ export class EditorResolverService extends Disposable implements IEditorResolver } updateUserAssociations(globPattern: string, editorID: string): void { + this.updateUserAssociationsForSetting(editorsAssociationsSettingId, globPattern, editorID); + } + + private updateUserAssociationsForType(associationType: EditorAssociationType, globPattern: string, editorID: string): void { + this.updateUserAssociationsForSetting(associationType === EditorAssociationType.DiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId, globPattern, editorID); + } + + private updateUserAssociationsForSetting(settingId: string, globPattern: string, editorID: string): void { const newAssociation: EditorAssociation = { viewType: editorID, filenamePattern: globPattern }; - const currentAssociations = this.getAllUserAssociations(); + const currentAssociations = this.getAllUserAssociationsForSetting(settingId); const newSettingObject = Object.create(null); // Form the new setting object including the newest associations for (const association of [...currentAssociations, newAssociation]) { @@ -349,16 +393,20 @@ export class EditorResolverService extends Disposable implements IEditorResolver newSettingObject[association.filenamePattern] = association.viewType; } } - this.configurationService.updateValue(editorsAssociationsSettingId, newSettingObject); + this.configurationService.updateValue(settingId, newSettingObject); } - private findMatchingEditors(resource: URI): RegisteredEditor[] { + private findMatchingEditors(resource: URI, associationType: EditorAssociationType = EditorAssociationType.Editor): RegisteredEditor[] { // The user setting should be respected even if the editor doesn't specify that resource in package.json - const userSettings = this.getAssociationsForResource(resource); + const userSettings = this.getAssociationsForResourceByType(resource, associationType); const matchingEditors: RegisteredEditor[] = []; // Then all glob patterns for (const [key, editors] of this._flattenedEditors) { for (const editor of editors) { + if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { + continue; + } + const foundInSettings = userSettings.find(setting => setting.viewType === editor.editorInfo.id); if ((foundInSettings && editor.editorInfo.priority !== RegisteredEditorPriority.exclusive) || globMatchesResource(key, resource)) { matchingEditors.push(editor); @@ -395,10 +443,14 @@ export class EditorResolverService extends Disposable implements IEditorResolver * Given a resource and an editorId selects the best possible editor * @returns The editor and whether there was another default which conflicted with it */ - private getEditor(resource: URI, editorId: string | EditorResolution.EXCLUSIVE_ONLY | undefined): { editor: RegisteredEditor | undefined; conflictingDefault: boolean } { + private getEditor(resource: URI, editorId: string | EditorResolution.EXCLUSIVE_ONLY | undefined, associationType: EditorAssociationType): { editor: RegisteredEditor | undefined; conflictingDefault: boolean } { const findMatchingEditor = (editors: RegisteredEditors, viewType: string) => { return editors.find((editor) => { + if (associationType === EditorAssociationType.DiffEditor && !editor.editorFactoryObject.createDiffEditorInput) { + return false; + } + if (editor.options?.canSupportResource !== undefined) { return editor.editorInfo.id === viewType && editor.options.canSupportResource(resource); } @@ -415,9 +467,9 @@ export class EditorResolverService extends Disposable implements IEditorResolver }; } - const editors = this.findMatchingEditors(resource); + const editors = this.findMatchingEditors(resource, associationType); - const associationsFromSetting = this.getAssociationsForResource(resource); + const associationsFromSetting = this.getAssociationsForResourceByType(resource, associationType); // We only want minPriority+ if no user defined setting is found, else we won't resolve an editor const minPriority = editorId === EditorResolution.EXCLUSIVE_ONLY ? RegisteredEditorPriority.exclusive : RegisteredEditorPriority.builtin; let possibleEditors = editors.filter(editor => priorityToRank(editor.editorInfo.priority) >= priorityToRank(minPriority) && editor.editorInfo.id !== DEFAULT_EDITOR_ASSOCIATION.id); @@ -594,7 +646,8 @@ export class EditorResolverService extends Disposable implements IEditorResolver type StoredChoice = { [key: string]: string[]; }; - const editors = this.findMatchingEditors(resource); + const associationType = isResourceDiffEditorInput(untypedInput) ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + const editors = this.findMatchingEditors(resource, associationType); const storedChoices: StoredChoice = JSON.parse(this.storageService.get(EditorResolverService.conflictingDefaultsStorageID, StorageScope.PROFILE, '{}')); const globForResource = `*${extname(resource)}`; // Writes to the storage service that a choice has been made for the currently installed editors @@ -646,13 +699,16 @@ export class EditorResolverService extends Disposable implements IEditorResolver }); } - private mapEditorsToQuickPickEntry(resource: URI, showDefaultPicker?: boolean) { + private mapEditorsToQuickPickEntry(resource: URI, showDefaultPicker: boolean | undefined, associationType: EditorAssociationType) { const currentEditor = this.editorGroupService.activeGroup.findEditors(resource).at(0); // If untitled, we want all registered editors - let registeredEditors = resource.scheme === Schemas.untitled ? this._registeredEditors.filter(e => e.editorInfo.priority !== RegisteredEditorPriority.exclusive) : this.findMatchingEditors(resource); + let registeredEditors = resource.scheme === Schemas.untitled ? this._registeredEditors.filter(e => e.editorInfo.priority !== RegisteredEditorPriority.exclusive) : this.findMatchingEditors(resource, associationType); + if (associationType === EditorAssociationType.DiffEditor) { + registeredEditors = registeredEditors.filter(editor => !!editor.editorFactoryObject.createDiffEditorInput); + } // We don't want duplicate Id entries registeredEditors = distinct(registeredEditors, c => c.editorInfo.id); - const defaultSetting = this.getAssociationsForResource(resource)[0]?.viewType; + const defaultSetting = this.getAssociationsForResourceByType(resource, associationType)[0]?.viewType; // Not the most efficient way to do this, but we want to ensure the text editor is at the top of the quickpick registeredEditors = registeredEditors.sort((a, b) => { if (a.editorInfo.id === DEFAULT_EDITOR_ASSOCIATION.id) { @@ -713,9 +769,10 @@ export class EditorResolverService extends Disposable implements IEditorResolver if (resource === undefined) { resource = URI.from({ scheme: Schemas.untitled }); } + const associationType = isResourceDiffEditorInput(editor) ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; // Get all the editors for the resource as quickpick entries - const editorPicks = this.mapEditorsToQuickPickEntry(resource, showDefaultPicker); + const editorPicks = this.mapEditorsToQuickPickEntry(resource, showDefaultPicker, associationType); // Create the editor picker const disposables = new DisposableStore(); @@ -746,7 +803,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver // If asked to always update the setting then update it even if the gear isn't clicked if (resource && showDefaultPicker && result?.item.id) { - this.updateUserAssociations(`*${extname(resource)}`, result.item.id,); + this.updateUserAssociationsForType(associationType, `*${extname(resource)}`, result.item.id); } resolve(result); @@ -764,7 +821,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver // Persist setting if (resource && e.item?.id) { - this.updateUserAssociations(`*${extname(resource)}`, e.item.id,); + this.updateUserAssociationsForType(associationType, `*${extname(resource)}`, e.item.id); } })); @@ -816,7 +873,10 @@ export class EditorResolverService extends Disposable implements IEditorResolver } // Also store the users settings as those would have to activate on startup as well - const userAssociations = this.getAllUserAssociations(); + const userAssociations = [ + ...this.getAllUserAssociations(), + ...this.getAllUserAssociationsForSetting(diffEditorsAssociationsSettingId) + ]; for (const association of userAssociations) { if (association.filenamePattern) { cacheStorage.add(association.filenamePattern); @@ -831,10 +891,10 @@ export class EditorResolverService extends Disposable implements IEditorResolver * the cache is empty), we still wait for extensions to register before * resolving the editor, so that user-configured custom editors are available. */ - private resourceMatchesUserAssociation(resource: URI): boolean { - const userAssociations = this.getAllUserAssociations(); + private resourceMatchesUserAssociation(resource: URI, associationType: EditorAssociationType): boolean { + const userAssociations = this.getRawAssociationsForResourceByType(resource, associationType); for (const association of userAssociations) { - if (association.filenamePattern && association.viewType !== DEFAULT_EDITOR_ASSOCIATION.id && globMatchesResource(association.filenamePattern, resource)) { + if (association.viewType !== DEFAULT_EDITOR_ASSOCIATION.id) { return true; } } diff --git a/src/vs/workbench/services/editor/common/editorResolverService.ts b/src/vs/workbench/services/editor/common/editorResolverService.ts index b45646fe14b431..09b6169f7b0162 100644 --- a/src/vs/workbench/services/editor/common/editorResolverService.ts +++ b/src/vs/workbench/services/editor/common/editorResolverService.ts @@ -35,18 +35,26 @@ export type EditorAssociation = { export type EditorAssociations = readonly EditorAssociation[]; export const editorsAssociationsSettingId = 'workbench.editorAssociations'; +export const diffEditorsAssociationsSettingId = 'workbench.diffEditorAssociations'; const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); const editorAssociationsConfigurationNode: IConfigurationNode = { ...workbenchConfigurationNodeBase, properties: { - 'workbench.editorAssociations': { + [editorsAssociationsSettingId]: { type: 'object', markdownDescription: localize('editor.editorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors (for example `\"*.hex\": \"hexEditor.hexedit\"`). These have precedence over the default behavior."), additionalProperties: { type: 'string' } + }, + [diffEditorsAssociationsSettingId]: { + type: 'object', + markdownDescription: localize('editor.diffEditorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors for diff views (for example `\"*.md\": \"vscode.markdown.preview.editor\"`). These override `workbench.editorAssociations` for diffs."), + additionalProperties: { + type: 'string' + } } } }; diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index 352c99a4befef9..96c70b95b247c7 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -12,7 +12,7 @@ import { EditorPart } from '../../../../browser/parts/editor/editorPart.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { EditorResolverService } from '../../browser/editorResolverService.js'; import { IEditorGroupsService } from '../../common/editorGroupsService.js'; -import { IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; +import { IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, diffEditorsAssociationsSettingId, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { createEditorPart, ITestInstantiationService, TestFileEditorInput, TestServiceAccessor, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; @@ -42,6 +42,16 @@ suite('EditorResolverService', () => { return editor; } + function constructDisposableDiffEditorInput(accessor: TestServiceAccessor, original: { readonly resource?: URI }, modified: { readonly resource?: URI }, typeId: string): DiffEditorInput { + return accessor.instantiationService.createInstance( + DiffEditorInput, + 'name', + 'description', + constructDisposableFileEditorInput(original.resource ?? URI.from({ scheme: Schemas.untitled }), typeId, disposables), + constructDisposableFileEditorInput(modified.resource ?? URI.from({ scheme: Schemas.untitled }), typeId, disposables), + undefined); + } + test('Simple Resolve', async () => { const [part, service] = await createEditorResolverService(); const registeredEditor = service.registerEditor('*.test', @@ -194,6 +204,150 @@ suite('EditorResolverService', () => { registeredEditor.dispose(); }); + test('Diff editor Resolve - Falls back to editor associations', async () => { + const CUSTOM_EDITOR_INPUT_ID = 'testCustomEditorInput'; + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [editorsAssociationsSettingId]: { + '*.test-diff-association': 'TEST_EDITOR' + } + }) + }, disposables); + const [part, service, accessor] = await createEditorResolverService(instantiationService); + let customDiffCounter = 0; + let defaultDiffCounter = 0; + + const defaultRegisteredEditor = service.registerEditor('*', + { + id: 'default', + label: 'Default Editor', + detail: 'Default', + priority: RegisteredEditorPriority.builtin + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, TEST_EDITOR_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + defaultDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, TEST_EDITOR_INPUT_ID) }; + } + } + ); + + const customRegisteredEditor = service.registerEditor('*.test-diff-association', + { + id: 'TEST_EDITOR', + label: 'Test Editor Label', + detail: 'Test Editor Details', + priority: RegisteredEditorPriority.option + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, CUSTOM_EDITOR_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + customDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, CUSTOM_EDITOR_INPUT_ID) }; + } + } + ); + + const resultingResolution = await service.resolveEditor({ + original: { resource: URI.file('resource-basics.test-diff-association') }, + modified: { resource: URI.file('resource-basics.test-diff-association') } + }, part.activeGroup); + assert.ok(resultingResolution); + assert.notStrictEqual(typeof resultingResolution, 'number'); + if (resultingResolution !== ResolvedStatus.ABORT && resultingResolution !== ResolvedStatus.NONE) { + assert.strictEqual(customDiffCounter, 1); + assert.strictEqual(defaultDiffCounter, 0); + resultingResolution.editor.dispose(); + } else { + assert.fail(); + } + + defaultRegisteredEditor.dispose(); + customRegisteredEditor.dispose(); + }); + + test('Diff editor Resolve - Diff associations override editor associations', async () => { + const EDITOR_ASSOCIATION_INPUT_ID = 'testEditorAssociationInput'; + const DIFF_ASSOCIATION_INPUT_ID = 'testDiffAssociationInput'; + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [editorsAssociationsSettingId]: { + '*.test-diff-association': 'TEST_EDITOR' + }, + [diffEditorsAssociationsSettingId]: { + '*.test-diff-association': 'TEST_DIFF_EDITOR' + } + }) + }, disposables); + const [part, service, accessor] = await createEditorResolverService(instantiationService); + let editorAssociationDiffCounter = 0; + let diffAssociationDiffCounter = 0; + + const editorAssociationRegisteredEditor = service.registerEditor('*.test-diff-association', + { + id: 'TEST_EDITOR', + label: 'Test Editor Label', + detail: 'Test Editor Details', + priority: RegisteredEditorPriority.option + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, EDITOR_ASSOCIATION_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + editorAssociationDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, EDITOR_ASSOCIATION_INPUT_ID) }; + } + } + ); + + const diffAssociationRegisteredEditor = service.registerEditor('*.test-diff-association', + { + id: 'TEST_DIFF_EDITOR', + label: 'Test Diff Editor Label', + detail: 'Test Diff Editor Details', + priority: RegisteredEditorPriority.option + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, DIFF_ASSOCIATION_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + diffAssociationDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, DIFF_ASSOCIATION_INPUT_ID) }; + } + } + ); + + const diffResolution = await service.resolveEditor({ + original: { resource: URI.file('resource-basics.test-diff-association') }, + modified: { resource: URI.file('resource-basics.test-diff-association') } + }, part.activeGroup); + assert.ok(diffResolution); + assert.notStrictEqual(typeof diffResolution, 'number'); + if (diffResolution !== ResolvedStatus.ABORT && diffResolution !== ResolvedStatus.NONE) { + assert.strictEqual(editorAssociationDiffCounter, 0); + assert.strictEqual(diffAssociationDiffCounter, 1); + diffResolution.editor.dispose(); + } else { + assert.fail(); + } + + const editorResolution = await service.resolveEditor({ resource: URI.file('resource-basics.test-diff-association') }, part.activeGroup); + assert.ok(editorResolution); + assert.notStrictEqual(typeof editorResolution, 'number'); + if (editorResolution !== ResolvedStatus.ABORT && editorResolution !== ResolvedStatus.NONE) { + assert.strictEqual(editorResolution.editor.typeId, EDITOR_ASSOCIATION_INPUT_ID); + editorResolution.editor.dispose(); + } else { + assert.fail(); + } + + editorAssociationRegisteredEditor.dispose(); + diffAssociationRegisteredEditor.dispose(); + }); + test('Diff editor Resolve - Different Types', async () => { const [part, service, accessor] = await createEditorResolverService(); let diffOneCounter = 0; diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index d8a488939d31bc..cec1f50ae4bb75 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -53,6 +53,7 @@ import { MockChatService } from '../../../../contrib/chat/test/common/chatServic import { ILanguageModelToolsService } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; import { IArtifactSourceGroup, IChatArtifacts, IChatArtifactsService } from '../../../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatTodo, IChatTodoListService } from '../../../../contrib/chat/common/tools/chatTodoListService.js'; +import { IChatToolRiskAssessmentService } from '../../../../contrib/chat/browser/tools/chatToolRiskAssessmentService.js'; import { ServiceRegistration, registerWorkbenchServices } from '../fixtureUtils.js'; /** @@ -177,6 +178,11 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.defineInstance(IChatModeService, new MockChatModeService()); reg.defineInstance(ILanguageModelsService, new class extends mock() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } }()); reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override onDidChangeTools = Event.None; override onDidPrepareToolCallBecomeUnresponsive = Event.None; override getTools() { return []; } }()); + reg.defineInstance(IChatToolRiskAssessmentService, new class extends mock() { + override isEnabled() { return false; } + override getCached() { return undefined; } + override async assess() { return undefined; } + }()); reg.defineInstance(IChatContextService, new class extends mock() { }()); reg.defineInstance(IChatContextPickService, new class extends mock() { }()); reg.defineInstance(IChatAttachmentWidgetRegistry, new class extends mock() { }()); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index 3c8f4937e1695a..8a1200ae8f5543 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -42,7 +42,6 @@ interface IFixtureMessage { interface IChatWidgetFixtureOptions { readonly messages: ReadonlyArray; - readonly withInput?: boolean; } function makeUserMessage(text: string) { @@ -161,33 +160,32 @@ async function renderChatWidget(context: ComponentFixtureContext, options: IChat // Build the input part FIRST so the widget (with its inputPart) is registered // in IChatWidgetService before the list widget renders. The renderer queries // the service synchronously when routing tool confirmations to the carousel. - let inputPart: ChatInputPart | undefined; - if (options.withInput) { - const menuService = instantiationService.get(IMenuService) as FixtureMenuService; - menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.attachContext', title: '+', icon: Codicon.add }, group: 'navigation', order: -1 }); - menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModePicker', title: 'Agent' }, group: 'navigation', order: 1 }); - menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModelPicker', title: 'GPT-5.3-Codex' }, group: 'navigation', order: 3 }); - menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.configureTools', title: '', icon: Codicon.settingsGear }, group: 'navigation', order: 100 }); - menuService.addItem(MenuId.ChatExecute, { command: { id: 'workbench.action.chat.submit', title: 'Send', icon: Codicon.arrowUp }, group: 'navigation', order: 4 }); - menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openSessionTargetPicker', title: 'Local' }, group: 'navigation', order: 0 }); - menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openPermissionPicker', title: 'Default Approvals' }, group: 'navigation', order: 10 }); + // In production a chat widget always has an inputPart, so the fixture creates + // one unconditionally; `withInput` only controls whether it is rendered in DOM. + const menuService = instantiationService.get(IMenuService) as FixtureMenuService; + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.attachContext', title: '+', icon: Codicon.add }, group: 'navigation', order: -1 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModePicker', title: 'Agent' }, group: 'navigation', order: 1 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModelPicker', title: 'GPT-5.3-Codex' }, group: 'navigation', order: 3 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.configureTools', title: '', icon: Codicon.settingsGear }, group: 'navigation', order: 100 }); + menuService.addItem(MenuId.ChatExecute, { command: { id: 'workbench.action.chat.submit', title: 'Send', icon: Codicon.arrowUp }, group: 'navigation', order: 4 }); + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openSessionTargetPicker', title: 'Local' }, group: 'navigation', order: 0 }); + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openPermissionPicker', title: 'Default Approvals' }, group: 'navigation', order: 10 }); - const inputOptions: IChatInputPartOptions = { - renderFollowups: false, - renderInputToolbarBelowInput: false, - renderWorkingSet: false, - menus: { executeToolbar: MenuId.ChatExecute, telemetrySource: 'fixture' }, - widgetViewKindTag: 'view', - inputEditorMinLines: 2, - }; - const inputStyles: IChatInputStyles = { - overlayBackground: 'var(--vscode-editor-background)', - listForeground: 'var(--vscode-foreground)', - listBackground: 'var(--vscode-editor-background)', - }; + const inputOptions: IChatInputPartOptions = { + renderFollowups: false, + renderInputToolbarBelowInput: false, + renderWorkingSet: false, + menus: { executeToolbar: MenuId.ChatExecute, telemetrySource: 'fixture' }, + widgetViewKindTag: 'view', + inputEditorMinLines: 2, + }; + const inputStyles: IChatInputStyles = { + overlayBackground: 'var(--vscode-editor-background)', + listForeground: 'var(--vscode-foreground)', + listBackground: 'var(--vscode-editor-background)', + }; - inputPart = disposableStore.add(instantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, inputOptions, inputStyles, false)); - } + const inputPart = disposableStore.add(instantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, inputOptions, inputStyles, false)); const fixtureWidget = new class extends mock() { override readonly onDidChangeViewModel = new Emitter().event; @@ -195,16 +193,14 @@ async function renderChatWidget(context: ComponentFixtureContext, options: IChat override readonly contribs = []; override readonly location = ChatAgentLocation.Chat; override readonly viewContext = {}; - override readonly inputPart = inputPart!; + override readonly inputPart = inputPart; }(); widgetHolder.current = fixtureWidget; - if (inputPart) { - inputPart.render(session, '', fixtureWidget); - inputPart.layout(720); - await new Promise(r => setTimeout(r, 50)); - inputPart.layout(720); - } + inputPart.render(session, '', fixtureWidget); + inputPart.layout(720); + await new Promise(r => setTimeout(r, 50)); + inputPart.layout(720); const listContainer = dom.$('.interactive-list'); listContainer.style.flex = '1 1 auto'; @@ -234,7 +230,7 @@ async function renderChatWidget(context: ComponentFixtureContext, options: IChat listWidget.setVisible(true); listWidget.refresh(); - const listHeight = options.withInput ? 420 : 600; + const listHeight = 420; listWidget.layout(listHeight, 720); // Allow the renderer to flush its async progressive rendering pass. @@ -297,7 +293,5 @@ export default defineThemedFixtureGroup({ path: 'chat/widget/' }, { SimpleQA: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: SIMPLE_QA }) }), Streaming: defineComponentFixture({ labels: { kind: 'animated' }, render: ctx => renderChatWidget(ctx, { messages: STREAMING }) }), PendingToolApproval: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL }) }), - PendingToolApprovalWithInput: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL, withInput: true }) }), MultiTurn: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: MULTI_TURN }) }), - WithInput: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: MULTI_TURN, withInput: true }) }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/codeEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/codeEditor.fixture.ts index af02594a3af1ea..a5aff4c10fda32 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/codeEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/codeEditor.fixture.ts @@ -54,7 +54,7 @@ function renderCodeEditor({ container, disposableStore, theme }: ComponentFixtur container, { automaticLayout: true, - minimap: { enabled: true }, + minimap: { enabled: false }, lineNumbers: 'on', scrollBeyondLastLine: false, fontSize: 14, diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index ece0d2e496f320..27135d0a7666d9 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -6,7 +6,7 @@ // This should be the only place that is allowed to import from @vscode/component-explorer // eslint-disable-next-line local/code-import-patterns import { defineFixture, defineFixtureGroup, defineFixtureVariants } from '@vscode/component-explorer'; -import { DisposableStore, DisposableTracker, setDisposableTracker, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, DisposableTracker, IDisposable, IReference, setDisposableTracker, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; // eslint-disable-next-line local/code-import-patterns import '../../../../../../build/vite/style.css'; @@ -92,7 +92,7 @@ import { IActionWidgetService } from '../../../../platform/actionWidget/browser/ import { IAnyWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; import { TestMenuService } from '../workbenchTestServices.js'; import { IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; // eslint-disable-next-line local/code-import-patterns import { IAgentFeedbackService } from '../../../../sessions/contrib/agentFeedback/browser/agentFeedbackService.js'; import { IChatEditingService } from '../../../contrib/chat/common/editing/chatEditingService.js'; @@ -109,7 +109,20 @@ import './fixtures.css'; // Import color registrations to ensure colors are available import { IdleDeadline, installFakeRunWhenIdle } from '../../../../base/common/async.js'; -import { AsyncSchedulerProcessor, TimeTravelScheduler, captureGlobalTimeApi, createLoggingTimeApi, createVirtualTimeApi, pushGlobalTimeApi } from '../../../../base/test/common/timeTravelScheduler.js'; +import { buildHistoryFromTasks, renderSwimlanes } from '../../../../base/test/common/executionGraph.js'; +import { + captureGlobalTimeApi, + createLoggingTimeApi, + createTraceRoot, + createVirtualTimeApi, + drainMicrotasksEmbedding, + nextMacrotask, + pushGlobalTimeApi, + TraceContext, + untilTime, + VirtualClock, + VirtualTimeProcessor, +} from '../../../../base/test/common/virtualScheduling/index.js'; import '../../../../platform/theme/common/colors/baseColors.js'; import '../../../../platform/theme/common/colors/editorColors.js'; import '../../../../platform/theme/common/colors/listColors.js'; @@ -243,7 +256,6 @@ import dark_vs from '../../../../../../extensions/theme-defaults/themes/dark_vs. import light_modern from '../../../../../../extensions/theme-defaults/themes/light_modern.json' with { type: 'json' }; import light_plus from '../../../../../../extensions/theme-defaults/themes/light_plus.json' with { type: 'json' }; import light_vs from '../../../../../../extensions/theme-defaults/themes/light_vs.json' with { type: 'json' }; -import { createTraceRoot, TraceContext } from '../../../../base/test/common/traceableTimeApi.js'; /* eslint-enable local/code-import-patterns */ const themeJsonModules: Record = { @@ -413,6 +425,56 @@ export interface CreateServicesOptions { additionalServices?: (registration: ServiceRegistration) => void; } +/** + * `ILogService` for fixtures that forwards `warn`, `error`, and `critical` + * to the browser console so that errors logged during render (e.g. from + * `try/catch` blocks that swallow errors into the log) become visible in + * the component-explorer console panel. + */ +export class FixtureLogService extends NullLogService { + override warn(message: string, ...args: unknown[]): void { + console.warn(message, ...args); + } + override error(message: string | Error, ...args: unknown[]): void { + console.error(message, ...args); + } + override critical(message: string | Error, ...args: unknown[]): void { + console.error(message, ...args); + } +} + +/** + * `ITextModelService` for fixtures that resolves URIs against `IModelService`. + * Models created via `createTextModel` (which uses `IModelService.createModel`) + * are automatically resolvable. URIs without a backing model fail loudly so + * that callers don't silently receive a null `textEditorModel`. + */ +export class FixtureTextModelService extends mock() { + constructor(@IModelService private readonly _modelService: IModelService) { + super(); + } + + override async createModelReference(resource: URI): Promise> { + const model = this._modelService.getModel(resource); + if (!model) { + throw new Error(`FixtureTextModelService: no model registered for ${resource.toString()}`); + } + return { + // eslint-disable-next-line local/code-no-dangerous-type-assertions + object: { textEditorModel: model } as IResolvedTextEditorModel, + dispose() { }, + }; + } + + override registerTextModelContentProvider(): IDisposable { + return { dispose() { } }; + } + + override canHandleResource(): boolean { + return false; + } +} + /** * Creates a TestInstantiationService with all services needed for CodeEditorWidget. * Additional services can be registered via the options callback. @@ -460,7 +522,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre } else { define(IThemeService, TestThemeService); } - define(ILogService, NullLogService); + define(ILogService, FixtureLogService); define(IModelService, ModelService); define(ICodeEditorService, TestCodeEditorService); define(IContextKeyService, MockContextKeyService); @@ -533,13 +595,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre onSoundEnabledChanged: () => Event.None, }); - definePartialInstance(ITextModelService, { - _serviceBrand: undefined, - registerTextModelContentProvider: () => ({ dispose: () => { } }), - canHandleResource: () => false, - // eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any - createModelReference: async () => ({ object: { textEditorModel: null }, dispose() { } } as any), - }); + define(ITextModelService, FixtureTextModelService); defineInstance(IAgentFeedbackService, { _serviceBrand: undefined, @@ -789,15 +845,17 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed async function actualRender() { const schedulerStore = disposableStore.add(new DisposableStore()); - const scheduler = new TimeTravelScheduler(Date.now()); - const p = schedulerStore.add(new AsyncSchedulerProcessor(scheduler, { - maxTaskCount: 100, + const clock = new VirtualClock(Date.now()); + const p = schedulerStore.add(new VirtualTimeProcessor( + clock, + drainMicrotasksEmbedding(realTimeApi), realTimeApi, - })); + { defaultMaxEvents: 100 }, + )); await setupTheme(container, theme); - const virtualTimeApi = createVirtualTimeApi(scheduler, { fakeRequestAnimationFrame: true }); + const virtualTimeApi = createVirtualTimeApi(clock, { fakeRequestAnimationFrame: true }); if (virtualTimeEnabled) { schedulerStore.add(pushGlobalTimeApi(virtualTimeApi)); @@ -805,8 +863,8 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed disposableStore.add(installFakeRunWhenIdle((_targetWindow, callback, _timeout?) => { const stackTrace = new Error().stack; const trace = TraceContext.instance.currentTrace().child('runWhenIdle', stackTrace); - return scheduler.schedule({ - time: scheduler.now, + return clock.schedule({ + time: clock.now, run: () => { const deadline: IdleDeadline = { didTimeout: true, @@ -827,29 +885,27 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed const result = options.render({ container, disposableStore, theme }); const p2 = virtualTimeEnabled - ? p.run({ virtualDeadline: scheduler.now + (options.virtualTime?.durationMs ?? 1000), maxTasks: 100, maxTaskDepth: 5 }) + ? p.run({ + until: untilTime(clock.now + (options.virtualTime?.durationMs ?? 1000)), + maxEvents: 200, + maxTraceDepth: 5, + }) : Promise.resolve(); await Promise.all([ result instanceof Promise ? result : Promise.resolve(), p2, ]); - } finally { + } catch (e) { if (virtualTimeEnabled && p.history.length > 0) { - // TODO - // const startTime = p.history[0].time; - // const history = buildHistoryFromTasks(p.history, startTime); - // console.log(`[ComponentFixture] ${themeLabel} virtual-time history (${p.history.length} tasks):\n${renderSwimlanes(history)}`); + const startTime = p.history[0].time; + const history = buildHistoryFromTasks(p.history, startTime); + console.error(`[ComponentFixture] ${theme === darkTheme ? 'Dark' : 'Light'} virtual-time history (${p.history.length} tasks):\n${renderSwimlanes(history)}`); } + throw e; + } finally { schedulerStore.dispose(); } - - const drain = false; - if (drain) { - disposableStore.add(toDisposable(() => { - p.run({ maxTasks: 100, maxTaskDepth: 5 }); - })); - } } // Every render gets its own trace root so that any diagnostics @@ -859,7 +915,10 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed const themeLabel = theme === darkTheme ? 'Dark' : 'Light'; const fixtureRoot = createTraceRoot(`render#${++fixtureRenderCounter}(${themeLabel})`); - await TraceContext.instance.runAsHandler(fixtureRoot, actualRender, realTimeApi); + await TraceContext.instance.runAsHandler(fixtureRoot, actualRender, { + // Trace-reset escapes virtual time so it actually fires. + afterMicrotaskClosure: cb => nextMacrotask(realTimeApi, cb), + }); }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts index d2d0511b29b311..1c06a3540bc7de 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts @@ -165,306 +165,370 @@ function renderSectionItem(ctx: ComponentFixtureContext, section: IAgentSessionS // ============================================================================ // Fixtures +// +// Each fixture computes `now` inside its render function so that timestamps +// anchor to the virtual clock at render time, not module-load time. Without +// this, real time keeps advancing between module load and render, making +// relative labels like "30 min ago" / "31 min ago" flake from one run to the +// next. // ============================================================================ -const now = Date.now(); - export default defineThemedFixtureGroup({ // --- Status variants --- CompletedRead: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Refactor auth middleware', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - timing: { - created: now - 2 * 60 * 60 * 1000, - lastRequestStarted: now - 2 * 60 * 60 * 1000, - lastRequestEnded: now - 2 * 60 * 60 * 1000 + 45 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Refactor auth middleware', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 60 * 1000, + lastRequestEnded: now - 2 * 60 * 60 * 1000 + 45 * 1000, + }, + })); + }, }), CompletedUnread: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Add unit tests for parser', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - isRead: () => false, - timing: { - created: now - 30 * 60 * 1000, - lastRequestStarted: now - 30 * 60 * 1000, - lastRequestEnded: now - 25 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Add unit tests for parser', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + isRead: () => false, + timing: { + created: now - 30 * 60 * 1000, + lastRequestStarted: now - 30 * 60 * 1000, + lastRequestEnded: now - 25 * 60 * 1000, + }, + })); + }, }), InProgress: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Implement dark mode toggle', - status: AgentSessionStatus.InProgress, - providerType: AgentSessionProviders.Local, - timing: { - created: now - 5 * 60 * 1000, - lastRequestStarted: now - 2 * 60 * 1000, - lastRequestEnded: undefined, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Implement dark mode toggle', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 5 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 1000, + lastRequestEnded: undefined, + }, + })); + }, }), NeedsInput: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Fix CI pipeline configuration', - status: AgentSessionStatus.NeedsInput, - providerType: AgentSessionProviders.Local, - isRead: () => false, - timing: { - created: now - 10 * 60 * 1000, - lastRequestStarted: now - 8 * 60 * 1000, - lastRequestEnded: undefined, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Fix CI pipeline configuration', + status: AgentSessionStatus.NeedsInput, + providerType: AgentSessionProviders.Local, + isRead: () => false, + timing: { + created: now - 10 * 60 * 1000, + lastRequestStarted: now - 8 * 60 * 1000, + lastRequestEnded: undefined, + }, + })); + }, }), FailedWithDuration: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Deploy staging environment', - status: AgentSessionStatus.Failed, - providerType: AgentSessionProviders.Local, - timing: { - created: now - 60 * 60 * 1000, - lastRequestStarted: now - 60 * 60 * 1000, - lastRequestEnded: now - 60 * 60 * 1000 + 3 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Deploy staging environment', + status: AgentSessionStatus.Failed, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 60 * 60 * 1000, + lastRequestStarted: now - 60 * 60 * 1000, + lastRequestEnded: now - 60 * 60 * 1000 + 3 * 60 * 1000, + }, + })); + }, }), FailedWithoutDuration: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Migrate database schema', - status: AgentSessionStatus.Failed, - providerType: AgentSessionProviders.Local, - timing: { - created: now - 3 * 60 * 60 * 1000, - lastRequestStarted: undefined, - lastRequestEnded: undefined, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Migrate database schema', + status: AgentSessionStatus.Failed, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 3 * 60 * 60 * 1000, + lastRequestStarted: undefined, + lastRequestEnded: undefined, + }, + })); + }, }), // --- Content variants --- WithDiffChanges: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Refactor settings page', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - changes: { files: 5, insertions: 142, deletions: 87 }, - timing: { - created: now - 45 * 60 * 1000, - lastRequestStarted: now - 45 * 60 * 1000, - lastRequestEnded: now - 40 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Refactor settings page', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + changes: { files: 5, insertions: 142, deletions: 87 }, + timing: { + created: now - 45 * 60 * 1000, + lastRequestStarted: now - 45 * 60 * 1000, + lastRequestEnded: now - 40 * 60 * 1000, + }, + })); + }, }), WithFileChangesList: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Update API endpoints', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Background, - icon: Codicon.worktree, - changes: [ - { modifiedUri: URI.file('/src/api/routes.ts'), insertions: 25, deletions: 10 }, - { modifiedUri: URI.file('/src/api/handlers.ts'), insertions: 50, deletions: 30 }, - { modifiedUri: URI.file('/tests/api.test.ts'), insertions: 40, deletions: 5 }, - ], - timing: { - created: now - 2 * 60 * 60 * 1000, - lastRequestStarted: now - 2 * 60 * 60 * 1000, - lastRequestEnded: now - 90 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Update API endpoints', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Background, + icon: Codicon.worktree, + changes: [ + { modifiedUri: URI.file('/src/api/routes.ts'), insertions: 25, deletions: 10 }, + { modifiedUri: URI.file('/src/api/handlers.ts'), insertions: 50, deletions: 30 }, + { modifiedUri: URI.file('/tests/api.test.ts'), insertions: 40, deletions: 5 }, + ], + timing: { + created: now - 2 * 60 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 60 * 1000, + lastRequestEnded: now - 90 * 60 * 1000, + }, + })); + }, }), WithBadge: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Optimize build pipeline', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - badge: 'PR #1234', - timing: { - created: now - 4 * 60 * 60 * 1000, - lastRequestStarted: now - 4 * 60 * 60 * 1000, - lastRequestEnded: now - 3.5 * 60 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Optimize build pipeline', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + badge: 'PR #1234', + timing: { + created: now - 4 * 60 * 60 * 1000, + lastRequestStarted: now - 4 * 60 * 60 * 1000, + lastRequestEnded: now - 3.5 * 60 * 60 * 1000, + }, + })); + }, }), WithMarkdownBadge: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Review security patches', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Cloud, - icon: Codicon.cloud, - badge: new MarkdownString('$(shield) Secure'), - timing: { - created: now - 6 * 60 * 60 * 1000, - lastRequestStarted: now - 6 * 60 * 60 * 1000, - lastRequestEnded: now - 5.5 * 60 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Review security patches', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Cloud, + icon: Codicon.cloud, + badge: new MarkdownString('$(shield) Secure'), + timing: { + created: now - 6 * 60 * 60 * 1000, + lastRequestStarted: now - 6 * 60 * 60 * 1000, + lastRequestEnded: now - 5.5 * 60 * 60 * 1000, + }, + })); + }, }), WithDescription: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Upgrade dependencies', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - description: 'Updated 12 packages to latest versions', - timing: { - created: now - 24 * 60 * 60 * 1000, - lastRequestStarted: now - 24 * 60 * 60 * 1000, - lastRequestEnded: now - 23.5 * 60 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Upgrade dependencies', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + description: 'Updated 12 packages to latest versions', + timing: { + created: now - 24 * 60 * 60 * 1000, + lastRequestStarted: now - 24 * 60 * 60 * 1000, + lastRequestEnded: now - 23.5 * 60 * 60 * 1000, + }, + })); + }, }), WithMarkdownDescription: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Fix accessibility issues', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - description: new MarkdownString('$(check) All WCAG checks passed'), - timing: { - created: now - 48 * 60 * 60 * 1000, - lastRequestStarted: now - 48 * 60 * 60 * 1000, - lastRequestEnded: now - 47 * 60 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Fix accessibility issues', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + description: new MarkdownString('$(check) All WCAG checks passed'), + timing: { + created: now - 48 * 60 * 60 * 1000, + lastRequestStarted: now - 48 * 60 * 60 * 1000, + lastRequestEnded: now - 47 * 60 * 60 * 1000, + }, + })); + }, }), WithBadgeAndDiff: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Implement search feature', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - badge: 'draft', - changes: { files: 8, insertions: 320, deletions: 45 }, - timing: { - created: now - 3 * 60 * 60 * 1000, - lastRequestStarted: now - 3 * 60 * 60 * 1000, - lastRequestEnded: now - 2.5 * 60 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Implement search feature', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + badge: 'draft', + changes: { files: 8, insertions: 320, deletions: 45 }, + timing: { + created: now - 3 * 60 * 60 * 1000, + lastRequestStarted: now - 3 * 60 * 60 * 1000, + lastRequestEnded: now - 2.5 * 60 * 60 * 1000, + }, + })); + }, }), // --- State variants --- Archived: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Old migration script', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - isArchived: () => true, - timing: { - created: now - 7 * 24 * 60 * 60 * 1000, - lastRequestStarted: now - 7 * 24 * 60 * 60 * 1000, - lastRequestEnded: now - 7 * 24 * 60 * 60 * 1000 + 10 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Old migration script', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + isArchived: () => true, + timing: { + created: now - 7 * 24 * 60 * 60 * 1000, + lastRequestStarted: now - 7 * 24 * 60 * 60 * 1000, + lastRequestEnded: now - 7 * 24 * 60 * 60 * 1000 + 10 * 60 * 1000, + }, + })); + }, }), ArchivedUnread: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Archived unread task', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Local, - isArchived: () => true, - isRead: () => false, - timing: { - created: now - 5 * 24 * 60 * 60 * 1000, - lastRequestStarted: now - 5 * 24 * 60 * 60 * 1000, - lastRequestEnded: now - 5 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Archived unread task', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + isArchived: () => true, + isRead: () => false, + timing: { + created: now - 5 * 24 * 60 * 60 * 1000, + lastRequestStarted: now - 5 * 24 * 60 * 60 * 1000, + lastRequestEnded: now - 5 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000, + }, + })); + }, }), // --- Provider-type variants --- CloudProvider: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Generate API documentation', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Cloud, - icon: Codicon.cloud, - timing: { - created: now - 90 * 60 * 1000, - lastRequestStarted: now - 90 * 60 * 1000, - lastRequestEnded: now - 80 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Generate API documentation', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Cloud, + icon: Codicon.cloud, + timing: { + created: now - 90 * 60 * 1000, + lastRequestStarted: now - 90 * 60 * 1000, + lastRequestEnded: now - 80 * 60 * 1000, + }, + })); + }, }), BackgroundProvider: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Run linter across codebase', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Background, - icon: Codicon.worktree, - timing: { - created: now - 120 * 60 * 1000, - lastRequestStarted: now - 120 * 60 * 1000, - lastRequestEnded: now - 110 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Run linter across codebase', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Background, + icon: Codicon.worktree, + timing: { + created: now - 120 * 60 * 1000, + lastRequestStarted: now - 120 * 60 * 1000, + lastRequestEnded: now - 110 * 60 * 1000, + }, + })); + }, }), ClaudeProvider: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Analyze code complexity', - status: AgentSessionStatus.Completed, - providerType: AgentSessionProviders.Claude, - icon: Codicon.claude, - timing: { - created: now - 150 * 60 * 1000, - lastRequestStarted: now - 150 * 60 * 1000, - lastRequestEnded: now - 140 * 60 * 1000, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Analyze code complexity', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Claude, + icon: Codicon.claude, + timing: { + created: now - 150 * 60 * 1000, + lastRequestStarted: now - 150 * 60 * 1000, + lastRequestEnded: now - 140 * 60 * 1000, + }, + })); + }, }), CloudProviderInProgress: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Build integration tests', - status: AgentSessionStatus.InProgress, - providerType: AgentSessionProviders.Cloud, - icon: Codicon.cloud, - isRead: () => false, - timing: { - created: now - 10 * 60 * 1000, - lastRequestStarted: now - 3 * 60 * 1000, - lastRequestEnded: undefined, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Build integration tests', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Cloud, + icon: Codicon.cloud, + isRead: () => false, + timing: { + created: now - 10 * 60 * 1000, + lastRequestStarted: now - 3 * 60 * 1000, + lastRequestEnded: undefined, + }, + })); + }, }), // --- In-progress with description override --- InProgressWithDescription: defineComponentFixture({ - render: (ctx) => renderSessionItem(ctx, createMockSession({ - label: 'Scaffold new microservice', - status: AgentSessionStatus.InProgress, - providerType: AgentSessionProviders.Background, - icon: Codicon.worktree, - description: 'Installing dependencies...', - timing: { - created: now - 5 * 60 * 1000, - lastRequestStarted: now - 60 * 1000, - lastRequestEnded: undefined, - }, - })), + render: (ctx) => { + const now = Date.now(); + renderSessionItem(ctx, createMockSession({ + label: 'Scaffold new microservice', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Background, + icon: Codicon.worktree, + description: 'Installing dependencies...', + timing: { + created: now - 5 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + })); + }, }), // --- Section headers --- @@ -521,6 +585,7 @@ export default defineThemedFixtureGroup({ ApprovalRowJson: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-json'); const approvalModel = createMockApprovalModel(resource, { label: '{ "action": "deleteFile", "path": "/src/old-module.ts" }', @@ -544,6 +609,7 @@ export default defineThemedFixtureGroup({ ApprovalRowBash: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-bash'); const approvalModel = createMockApprovalModel(resource, { label: 'npm install --save express@latest', @@ -567,6 +633,7 @@ export default defineThemedFixtureGroup({ ApprovalRowPowerShell: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-powershell'); const approvalModel = createMockApprovalModel(resource, { label: 'Start-Job -ScriptBlock { Set-Location \'c:\\some\\path\'; npm install } | Out-Null', @@ -590,6 +657,7 @@ export default defineThemedFixtureGroup({ ApprovalRowLongLabel: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-long'); const approvalModel = createMockApprovalModel(resource, { label: 'rm -rf node_modules && npm cache clean --force && npm install --legacy-peer-deps --ignore-scripts', @@ -615,6 +683,7 @@ export default defineThemedFixtureGroup({ ApprovalRow1Line: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-1line'); const approvalModel = createMockApprovalModel(resource, { label: 'npm install --save express@latest', @@ -638,6 +707,7 @@ export default defineThemedFixtureGroup({ ApprovalRow2Lines: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-2lines'); const approvalModel = createMockApprovalModel(resource, { label: 'cd /workspace/project\nnpm install', @@ -661,6 +731,7 @@ export default defineThemedFixtureGroup({ ApprovalRow3Lines: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3lines'); const approvalModel = createMockApprovalModel(resource, { label: 'cd /workspace/project\nnpm install\nnpm run build', @@ -684,6 +755,7 @@ export default defineThemedFixtureGroup({ ApprovalRow4Lines: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-4lines'); const approvalModel = createMockApprovalModel(resource, { label: 'cd /workspace/project\nnpm install\nnpm run build\nnpm run test -- --coverage', @@ -707,6 +779,7 @@ export default defineThemedFixtureGroup({ ApprovalRow3LongLines: defineComponentFixture({ render: (ctx) => { + const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3longlines'); const approvalModel = createMockApprovalModel(resource, { label: 'RUSTFLAGS="-C target-cpu=native -C opt-level=3" cargo build --release --target x86_64-unknown-linux-gnu\nfind ./target/release -name "*.so" -exec strip --strip-unneeded {} \\; && tar czf release-bundle.tar.gz -C target/release .\ncurl -X POST https://deploy.internal.example.com/api/v2/artifacts/upload --header "Authorization: Bearer $DEPLOY_TOKEN" --form "bundle=@release-bundle.tar.gz"', diff --git a/src/vscode-dts/vscode.proposed.customEditorDiffs.d.ts b/src/vscode-dts/vscode.proposed.customEditorDiffs.d.ts new file mode 100644 index 00000000000000..e75e38c5305a85 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.customEditorDiffs.d.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface CustomEditorDiffDocuments { + readonly original: T; + readonly modified: T; + } + + export interface CustomEditorDiffWebviewPanels { + readonly original: WebviewPanel; + readonly modified: WebviewPanel; + } + + export interface CustomReadonlyEditorProvider { + + /** + * Resolve a custom editor the shows the diff between two documents using a single webview. + * + * @param documents Original and modified documents for the diff editor. + * @param webviewPanel The webview panel used to display the editor UI for this diff. + * @param token A cancellation token that indicates the result is no longer needed. + * + * @returns Thenable indicating that the custom diff editor has been resolved. + */ + resolveCustomEditorInlineDiff?(documents: CustomEditorDiffDocuments, webviewPanel: WebviewPanel, token: CancellationToken): Thenable | void; + + /** + * Resolve a side-by-side custom editor diff between two custom documents. + * + * @param documents Original and modified documents for the diff editor. + * @param webviewPanels The webview panels used to display the original and modified sides of the diff. + * @param token A cancellation token that indicates the result is no longer needed. + * + * @returns Thenable indicating that the custom diff editor has been resolved. + */ + resolveCustomEditorSideBySideDiff?(documents: CustomEditorDiffDocuments, webviewPanels: CustomEditorDiffWebviewPanels, token: CancellationToken): Thenable | void; + } + + export interface CustomTextEditorProvider { + + /** + * Resolve a custom editor for a diff between two text resources using a single webview. + * + * @param documents Original and modified documents for the diff editor. + * @param webviewPanel The webview panel used to display the editor UI for this diff. + * @param token A cancellation token that indicates the result is no longer needed. + * + * @returns Thenable indicating that the custom diff editor has been resolved. + */ + resolveCustomTextEditorInlineDiff?(documents: CustomEditorDiffDocuments, webviewPanel: WebviewPanel, token: CancellationToken): Thenable | void; + + /** + * Resolve a side-by-side custom editor diff between two text resources. + * + * @param documents Original and modified documents for the diff editor. + * @param webviewPanels The webview panels used to display the original and modified sides of the diff. + * @param token A cancellation token that indicates the result is no longer needed. + * + * @returns Thenable indicating that the custom diff editor has been resolved. + */ + resolveCustomTextEditorSideBySideDiff?(documents: CustomEditorDiffDocuments, webviewPanels: CustomEditorDiffWebviewPanels, token: CancellationToken): Thenable | void; + } +} diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index a2b209fbce62e6..0b1c2de1be37c4 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -1,10 +1,10 @@ #### editor/codeEditor/CodeEditor/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7233cfd6ccf691f30019d2a9f8de20d6bac0af4e79ff23c43ac32e40e56fbbb9) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/67bfb687fd2818bd53771a60660541b9ed6f38b80d37da0aac15d267ecaeacec) #### editor/codeEditor/CodeEditor/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/da11fcac980961a5db956bb68f39794836f648f5c8349ad0df673424626d1755) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0469dd8d0a587d94a1eaec514c79917b93b9a38694ef2b767bb1892819ae0a55) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/eee9299ca979bc241139e5c36fd03e8ad7b677fd2626d2f9f149fe39828c22cb)