diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 18bd0027fdc59..59a0c8677fe11 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -54,13 +54,9 @@ }, "simpleBrowser.useIntegratedBrowser": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "%configuration.useIntegratedBrowser.description%", - "scope": "application", - "tags": [ - "experimental", - "onExP" - ] + "scope": "application" } } } diff --git a/extensions/simple-browser/package.nls.json b/extensions/simple-browser/package.nls.json index 3b6b41530fa04..0b88b068fbc51 100644 --- a/extensions/simple-browser/package.nls.json +++ b/extensions/simple-browser/package.nls.json @@ -2,5 +2,5 @@ "displayName": "Simple Browser", "description": "A very basic built-in webview for displaying web content.", "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser.", - "configuration.useIntegratedBrowser.description": "When enabled, the `simpleBrowser.show` command will open URLs in the integrated browser instead of the Simple Browser webview. **Note:** This setting is experimental and only available on desktop." + "configuration.useIntegratedBrowser.description": "When enabled, the `simpleBrowser.show` command will open URLs in the integrated browser instead of the Simple Browser webview. **Note:** This setting is only available on desktop." } diff --git a/extensions/simple-browser/src/extension.ts b/extensions/simple-browser/src/extension.ts index 6eb0bb0837f11..75ee87d4da708 100644 --- a/extensions/simple-browser/src/extension.ts +++ b/extensions/simple-browser/src/extension.ts @@ -38,7 +38,7 @@ const openerId = 'simpleBrowser.open'; */ async function shouldUseIntegratedBrowser(): Promise { const config = vscode.workspace.getConfiguration(); - if (!config.get(useIntegratedBrowserSetting, false)) { + if (!config.get(useIntegratedBrowserSetting, true)) { return false; } diff --git a/package-lock.json b/package-lock.json index 33c4fa7383bc5..2b82a31c00847 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", - "playwright-core": "^1.58.2", + "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", @@ -14529,9 +14529,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.59.0-alpha-2026-02-20", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", + "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index 73b8625209ed0..a1f42fcaa675b 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.10", "open": "^10.1.2", - "playwright-core": "^1.58.2", + "playwright-core": "1.59.0-alpha-2026-02-20", "tas-client": "0.3.1", "undici": "^7.18.2", "v8-inspect-profiler": "^0.1.1", diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 638fafb925af2..4d3ae947ad6e5 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -40,7 +40,7 @@ import { EditSources, TextModelEditSource } from '../textModelEditSource.js'; import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelOptionsChangedEvent, InternalModelContentChangeEvent, LineInjectedText, ModelFontChanged, ModelFontChangedEvent, ModelInjectedTextChangedEvent, ModelLineHeightChanged, ModelLineHeightChangedEvent, ModelRawChange, ModelRawContentChangedEvent, ModelRawEOLChanged, ModelRawFlush, ModelRawLineChanged, ModelRawLinesDeleted, ModelRawLinesInserted } from '../textModelEvents.js'; import { IGuidesTextModelPart } from '../textModelGuides.js'; import { ITokenizationTextModelPart } from '../tokenizationTextModelPart.js'; -import { TokenArray } from '../tokens/lineTokens.js'; +import { LineTokens, TokenArray } from '../tokens/lineTokens.js'; import { BracketPairsTextModelPart } from './bracketPairsTextModelPart/bracketPairsImpl.js'; import { ColorizedBracketPairsDecorationProvider } from './bracketPairsTextModelPart/colorizedBracketPairsDecorationProvider.js'; import { EditStack } from './editStack.js'; @@ -2161,6 +2161,37 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati } } +export function getLineTokensWithInjections(tokens: LineTokens, injectionOptions: model.InjectedTextOptions[] | null, injectionOffsets: number[] | null): LineTokens { + let lineTokens: LineTokens; + if (injectionOffsets) { + const tokensToInsert: { offset: number; text: string; tokenMetadata: number }[] = []; + + for (let idx = 0; idx < injectionOffsets.length; idx++) { + const offset = injectionOffsets[idx]; + const tokens = injectionOptions![idx].tokens; + if (tokens) { + tokens.forEach((range, info) => { + tokensToInsert.push({ + offset, + text: range.substring(injectionOptions![idx].content), + tokenMetadata: info.metadata, + }); + }); + } else { + tokensToInsert.push({ + offset, + text: injectionOptions![idx].content, + tokenMetadata: LineTokens.defaultTokenMetadata, + }); + } + } + lineTokens = tokens.withInserted(tokensToInsert); + } else { + lineTokens = tokens; + } + return lineTokens; +} + export function indentOfLine(line: string): number { let indent = 0; for (const c of line) { diff --git a/src/vs/editor/common/viewModel.ts b/src/vs/editor/common/viewModel.ts index 39b6149e8aa97..37ccca993c4f6 100644 --- a/src/vs/editor/common/viewModel.ts +++ b/src/vs/editor/common/viewModel.ts @@ -21,7 +21,7 @@ import { BracketGuideOptions, IActiveIndentGuideInfo, IndentGuide } from './text import { IViewLineTokens } from './tokens/lineTokens.js'; import { ViewEventHandler } from './viewEventHandler.js'; import { VerticalRevealType } from './viewEvents.js'; -import { InlineDecoration, SingleLineInlineDecoration } from './viewModel/inlineDecorations.js'; +import { InlineDecoration } from './viewModel/inlineDecorations.js'; import { EditorOption, FindComputedEditorOptionValueById } from './config/editorOptions.js'; export interface IViewModel extends ICursorSimpleModel, ISimpleModel { @@ -278,7 +278,7 @@ export class ViewLineData { /** * Additional inline decorations for this line. */ - public readonly inlineDecorations: readonly SingleLineInlineDecoration[] | null; + public readonly inlineDecorations: readonly InlineDecoration[] | null; constructor( content: string, @@ -287,7 +287,7 @@ export class ViewLineData { maxColumn: number, startVisibleColumn: number, tokens: IViewLineTokens, - inlineDecorations: readonly SingleLineInlineDecoration[] | null + inlineDecorations: readonly InlineDecoration[] | null ) { this.content = content; this.continuesWithWrappedLine = continuesWithWrappedLine; diff --git a/src/vs/editor/common/viewModel/inlineDecorations.ts b/src/vs/editor/common/viewModel/inlineDecorations.ts index c33336342f013..f6869c57dd334 100644 --- a/src/vs/editor/common/viewModel/inlineDecorations.ts +++ b/src/vs/editor/common/viewModel/inlineDecorations.ts @@ -3,7 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IModelDecoration, InjectedTextOptions, ITextModel, PositionAffinity } from '../model.js'; import { Range } from '../core/range.js'; +import { Position } from '../core/position.js'; +import { ICoordinatesConverter } from '../coordinatesConverter.js'; +import { isModelDecorationVisible, ViewModelDecoration } from './viewModelDecoration.js'; export const enum InlineDecorationType { Regular = 0, @@ -20,20 +24,246 @@ export class InlineDecoration { ) { } } -export class SingleLineInlineDecoration { +/** + * A collection of decorations in a range of lines. + */ +export interface IViewDecorationsCollection { + /** + * decorations in the range of lines (ungrouped). + */ + readonly decorations: ViewModelDecoration[]; + /** + * inline decorations (grouped by each line in the range of lines). + */ + readonly inlineDecorations: InlineDecoration[][]; + /** + * Whether the decorations affect the fonts. + */ + readonly hasVariableFonts: boolean[]; +} + +export interface IInlineDecorationsComputer { + /** + * Get the inline decorations for a specific model line number, split by view line number + */ + getInlineDecorations(modelLineNumber: number): InlineDecoration[][]; +} + +export interface IInlineModelDecorationsComputerContext { + /** + * Get model decorations for a view range + */ + getModelDecorations(viewRange: Range, onlyMinimapDecorations: boolean, onlyMarginDecorations: boolean): IModelDecoration[]; +} + +export class InlineModelDecorationsComputer implements IInlineDecorationsComputer { + + private _decorationsCache: { [decorationId: string]: ViewModelDecoration }; + constructor( - public readonly startOffset: number, - public readonly endOffset: number, - public readonly inlineClassName: string, - public readonly inlineClassNameAffectsLetterSpacing: boolean + private readonly context: IInlineModelDecorationsComputerContext, + private readonly model: ITextModel, + private readonly coordinatesConverter: ICoordinatesConverter ) { + this._decorationsCache = Object.create(null); + } + + public getInlineDecorations(modelLineNumber: number): InlineDecoration[][] { + const modelRange = new Range(modelLineNumber, 1, modelLineNumber, this.model.getLineMaxColumn(modelLineNumber)); + const viewRange = this.coordinatesConverter.convertModelRangeToViewRange(modelRange); + const decorationsViewportData = this.getDecorations(viewRange, false, false); + return decorationsViewportData.inlineDecorations; + } + + public getDecorations(viewRange: Range, onlyMinimapDecorations: boolean, onlyMarginDecorations: boolean): IViewDecorationsCollection { + const modelDecorations = this.context.getModelDecorations(viewRange, onlyMinimapDecorations, onlyMarginDecorations); + const startLineNumber = viewRange.startLineNumber; + const endLineNumber = viewRange.endLineNumber; + + const decorationsInViewport: ViewModelDecoration[] = []; + let decorationsInViewportLen = 0; + const inlineDecorations: InlineDecoration[][] = []; + const hasVariableFonts: boolean[] = []; + for (let j = startLineNumber; j <= endLineNumber; j++) { + inlineDecorations[j - startLineNumber] = []; + hasVariableFonts[j - startLineNumber] = false; + } + + for (let i = 0, len = modelDecorations.length; i < len; i++) { + const modelDecoration = modelDecorations[i]; + const decorationOptions = modelDecoration.options; + + if (!isModelDecorationVisible(this.model, modelDecoration)) { + continue; + } + + const viewModelDecoration = this._getOrCreateViewModelDecoration(modelDecoration); + const viewRange = viewModelDecoration.range; + + decorationsInViewport[decorationsInViewportLen++] = viewModelDecoration; + + if (decorationOptions.inlineClassName) { + const inlineDecoration = new InlineDecoration(viewRange, decorationOptions.inlineClassName, decorationOptions.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular); + const intersectedStartLineNumber = Math.max(startLineNumber, viewRange.startLineNumber); + const intersectedEndLineNumber = Math.min(endLineNumber, viewRange.endLineNumber); + for (let j = intersectedStartLineNumber; j <= intersectedEndLineNumber; j++) { + inlineDecorations[j - startLineNumber].push(inlineDecoration); + if (decorationOptions.affectsFont) { + hasVariableFonts[j - startLineNumber] = true; + } + } + } + if (decorationOptions.beforeContentClassName) { + if (startLineNumber <= viewRange.startLineNumber && viewRange.startLineNumber <= endLineNumber) { + const inlineDecoration = new InlineDecoration( + new Range(viewRange.startLineNumber, viewRange.startColumn, viewRange.startLineNumber, viewRange.startColumn), + decorationOptions.beforeContentClassName, + InlineDecorationType.Before + ); + inlineDecorations[viewRange.startLineNumber - startLineNumber].push(inlineDecoration); + if (decorationOptions.affectsFont) { + hasVariableFonts[viewRange.startLineNumber - startLineNumber] = true; + } + } + } + if (decorationOptions.afterContentClassName) { + if (startLineNumber <= viewRange.endLineNumber && viewRange.endLineNumber <= endLineNumber) { + const inlineDecoration = new InlineDecoration( + new Range(viewRange.endLineNumber, viewRange.endColumn, viewRange.endLineNumber, viewRange.endColumn), + decorationOptions.afterContentClassName, + InlineDecorationType.After + ); + inlineDecorations[viewRange.endLineNumber - startLineNumber].push(inlineDecoration); + if (decorationOptions.affectsFont) { + hasVariableFonts[viewRange.endLineNumber - startLineNumber] = true; + } + } + } + } + + return { + decorations: decorationsInViewport, + inlineDecorations: inlineDecorations, + hasVariableFonts + }; } - toInlineDecoration(lineNumber: number): InlineDecoration { - return new InlineDecoration( - new Range(lineNumber, this.startOffset + 1, lineNumber, this.endOffset + 1), - this.inlineClassName, - this.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular - ); + public reset(): void { + this._decorationsCache = Object.create(null); + } + + public onModelDecorationsChanged(): void { + this.reset(); + } + + public onLineMappingChanged(): void { + this.reset(); + } + + private _getOrCreateViewModelDecoration(modelDecoration: IModelDecoration): ViewModelDecoration { + const id = modelDecoration.id; + let r = this._decorationsCache[id]; + if (!r) { + const modelRange = modelDecoration.range; + const options = modelDecoration.options; + let viewRange: Range; + if (options.isWholeLine) { + const start = this.coordinatesConverter.convertModelPositionToViewPosition(new Position(modelRange.startLineNumber, 1), PositionAffinity.Left, false, true); + const end = this.coordinatesConverter.convertModelPositionToViewPosition(new Position(modelRange.endLineNumber, this.model.getLineMaxColumn(modelRange.endLineNumber)), PositionAffinity.Right); + viewRange = new Range(start.lineNumber, start.column, end.lineNumber, end.column); + } else { + // For backwards compatibility reasons, we want injected text before any decoration. + // Thus, move decorations to the right. + viewRange = this.coordinatesConverter.convertModelRangeToViewRange(modelRange, PositionAffinity.Right); + } + r = new ViewModelDecoration(viewRange, options); + this._decorationsCache[id] = r; + } + return r; + } +} + +export interface IInjectedTextInlineDecorationsComputerContext { + /** + * Get the injections options for a model line number + */ + getInjectionOptions(modelLineNumber: number): InjectedTextOptions[] | null; + /** + * Get the injection offsets for a model line number + */ + getInjectionOffsets(modelLineNumber: number): number[] | null; + /** + * Get the break offets for a model line number + */ + getBreakOffsets(modelLineNumber: number): number[]; + /** + * Get the wrapped text indent length for a model line number + */ + getWrappedTextIndentLength(modelLineNumber: number): number; + /** + * Get the view line number for the first output line of a model line + */ + getBaseViewLineNumber(modelLineNumber: number): number; +} + +export class InjectedTextInlineDecorationsComputer implements IInlineDecorationsComputer { + + constructor(private readonly context: IInjectedTextInlineDecorationsComputerContext) { } + + public getInlineDecorations(modelLineNumber: number): InlineDecoration[][] { + const injectionOffsets = this.context.getInjectionOffsets(modelLineNumber); + if (!injectionOffsets) { + return []; + } + const lineInlineDecorations = []; + let totalInjectedTextLengthBefore = 0; + let currentInjectedOffset = 0; + + const injectionOptions = this.context.getInjectionOptions(modelLineNumber); + const breakOffsets = this.context.getBreakOffsets(modelLineNumber); + + for (let outputLineIndex = 0; outputLineIndex < breakOffsets.length; outputLineIndex++) { + const inlineDecorations = new Array(); + lineInlineDecorations[outputLineIndex] = inlineDecorations; + + const lineStartOffsetInInputWithInjections = outputLineIndex > 0 ? breakOffsets[outputLineIndex - 1] : 0; + const lineEndOffsetInInputWithInjections = breakOffsets[outputLineIndex]; + + while (currentInjectedOffset < injectionOffsets.length) { + const length = injectionOptions![currentInjectedOffset].content.length; + const injectedTextStartOffsetInInputWithInjections = injectionOffsets[currentInjectedOffset] + totalInjectedTextLengthBefore; + const injectedTextEndOffsetInInputWithInjections = injectedTextStartOffsetInInputWithInjections + length; + + if (injectedTextStartOffsetInInputWithInjections > lineEndOffsetInInputWithInjections) { + // Injected text only starts in later wrapped lines. + break; + } + + if (lineStartOffsetInInputWithInjections < injectedTextEndOffsetInInputWithInjections) { + // Injected text ends after or in this line (but also starts in or before this line). + const options = injectionOptions![currentInjectedOffset]; + if (options.inlineClassName) { + const wrappedTextIndentLength = this.context.getWrappedTextIndentLength(modelLineNumber); + const offset = (outputLineIndex > 0 ? wrappedTextIndentLength : 0); + const start = offset + Math.max(injectedTextStartOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, 0); + const end = offset + Math.min(injectedTextEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, lineEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections); + if (start !== end) { + const viewLineNumber = this.context.getBaseViewLineNumber(modelLineNumber) + outputLineIndex; + const range = new Range(viewLineNumber, start + 1, viewLineNumber, end + 1); + const type: InlineDecorationType = options.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular; + inlineDecorations.push(new InlineDecoration(range, options.inlineClassName, type)); + } + } + } + if (injectedTextEndOffsetInInputWithInjections <= lineEndOffsetInInputWithInjections) { + totalInjectedTextLengthBefore += length; + currentInjectedOffset++; + } else { + // injected text breaks into next line, process it again + break; + } + } + } + return lineInlineDecorations; } } diff --git a/src/vs/editor/common/viewModel/modelLineProjection.ts b/src/vs/editor/common/viewModel/modelLineProjection.ts index d98e4aa98cf77..06393f28b1afe 100644 --- a/src/vs/editor/common/viewModel/modelLineProjection.ts +++ b/src/vs/editor/common/viewModel/modelLineProjection.ts @@ -10,7 +10,8 @@ import { EndOfLinePreference, ITextModel, PositionAffinity } from '../model.js'; import { LineInjectedText } from '../textModelEvents.js'; import { InjectedText, ModelLineProjectionData } from '../modelLineProjectionData.js'; import { ViewLineData } from '../viewModel.js'; -import { SingleLineInlineDecoration } from './inlineDecorations.js'; +import { IInjectedTextInlineDecorationsComputerContext, InjectedTextInlineDecorationsComputer, InlineDecoration } from './inlineDecorations.js'; +import { getLineTokensWithInjections } from '../model/textModel.js'; export interface IModelLineProjection { isVisible(): boolean; @@ -26,8 +27,8 @@ export interface IModelLineProjection { getViewLineLength(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number): number; getViewLineMinColumn(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number): number; getViewLineMaxColumn(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number): number; - getViewLineData(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number): ViewLineData; - getViewLinesData(model: ISimpleModel, modelLineNumber: number, outputLineIdx: number, lineCount: number, globalStartIndex: number, needed: boolean[], result: Array): void; + getViewLineData(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, baseViewLineNumber: number): ViewLineData; + getViewLinesData(model: ISimpleModel, modelLineNumber: number, outputLineIdx: number, lineCount: number, baseViewLineNumber: number, globalStartIndex: number, needed: boolean[], result: Array): void; getModelColumnOfViewPosition(outputLineIndex: number, outputColumn: number): number; getViewPositionOfModelPosition(deltaLineNumber: number, inputColumn: number, affinity?: PositionAffinity): Position; @@ -150,13 +151,13 @@ class ModelLineProjection implements IModelLineProjection { /** * Try using {@link getViewLinesData} instead. */ - public getViewLineData(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number): ViewLineData { + public getViewLineData(model: ISimpleModel, modelLineNumber: number, outputLineIndex: number, baseViewLineNumber: number): ViewLineData { const arr = new Array(); - this.getViewLinesData(model, modelLineNumber, outputLineIndex, 1, 0, [true], arr); + this.getViewLinesData(model, modelLineNumber, outputLineIndex, 1, baseViewLineNumber, 0, [true], arr); return arr[0]; } - public getViewLinesData(model: ISimpleModel, modelLineNumber: number, outputLineIdx: number, lineCount: number, globalStartIndex: number, needed: boolean[], result: Array): void { + public getViewLinesData(model: ISimpleModel, modelLineNumber: number, outputLineIdx: number, lineCount: number, baseViewLineNumber: number, globalStartIndex: number, needed: boolean[], result: Array): void { this._assertVisible(); const lineBreakData = this._projectionData; @@ -164,82 +165,17 @@ class ModelLineProjection implements IModelLineProjection { const injectionOffsets = lineBreakData.injectionOffsets; const injectionOptions = lineBreakData.injectionOptions; - let inlineDecorationsPerOutputLine: SingleLineInlineDecoration[][] | null = null; - - if (injectionOffsets) { - inlineDecorationsPerOutputLine = []; - let totalInjectedTextLengthBefore = 0; - let currentInjectedOffset = 0; - - for (let outputLineIndex = 0; outputLineIndex < lineBreakData.getOutputLineCount(); outputLineIndex++) { - const inlineDecorations = new Array(); - inlineDecorationsPerOutputLine[outputLineIndex] = inlineDecorations; - - const lineStartOffsetInInputWithInjections = outputLineIndex > 0 ? lineBreakData.breakOffsets[outputLineIndex - 1] : 0; - const lineEndOffsetInInputWithInjections = lineBreakData.breakOffsets[outputLineIndex]; - - while (currentInjectedOffset < injectionOffsets.length) { - const length = injectionOptions![currentInjectedOffset].content.length; - const injectedTextStartOffsetInInputWithInjections = injectionOffsets[currentInjectedOffset] + totalInjectedTextLengthBefore; - const injectedTextEndOffsetInInputWithInjections = injectedTextStartOffsetInInputWithInjections + length; - - if (injectedTextStartOffsetInInputWithInjections > lineEndOffsetInInputWithInjections) { - // Injected text only starts in later wrapped lines. - break; - } - - if (lineStartOffsetInInputWithInjections < injectedTextEndOffsetInInputWithInjections) { - // Injected text ends after or in this line (but also starts in or before this line). - const options = injectionOptions![currentInjectedOffset]; - if (options.inlineClassName) { - const offset = (outputLineIndex > 0 ? lineBreakData.wrappedTextIndentLength : 0); - const start = offset + Math.max(injectedTextStartOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, 0); - const end = offset + Math.min(injectedTextEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, lineEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections); - if (start !== end) { - inlineDecorations.push(new SingleLineInlineDecoration(start, end, options.inlineClassName, options.inlineClassNameAffectsLetterSpacing!)); - } - } - } - - if (injectedTextEndOffsetInInputWithInjections <= lineEndOffsetInInputWithInjections) { - totalInjectedTextLengthBefore += length; - currentInjectedOffset++; - } else { - // injected text breaks into next line, process it again - break; - } - } - } - } - - let lineWithInjections: LineTokens; - if (injectionOffsets) { - const tokensToInsert: { offset: number; text: string; tokenMetadata: number }[] = []; - - for (let idx = 0; idx < injectionOffsets.length; idx++) { - const offset = injectionOffsets[idx]; - const tokens = injectionOptions![idx].tokens; - if (tokens) { - tokens.forEach((range, info) => { - tokensToInsert.push({ - offset, - text: range.substring(injectionOptions![idx].content), - tokenMetadata: info.metadata, - }); - }); - } else { - tokensToInsert.push({ - offset, - text: injectionOptions![idx].content, - tokenMetadata: LineTokens.defaultTokenMetadata, - }); - } - } - - lineWithInjections = model.tokenization.getLineTokens(modelLineNumber).withInserted(tokensToInsert); - } else { - lineWithInjections = model.tokenization.getLineTokens(modelLineNumber); - } + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => injectionOffsets, + getBreakOffsets: () => lineBreakData.breakOffsets, + getWrappedTextIndentLength: () => lineBreakData.wrappedTextIndentLength, + getBaseViewLineNumber: () => baseViewLineNumber + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const lineInlineDecorations = computer.getInlineDecorations(modelLineNumber); + const lineTokens = model.tokenization.getLineTokens(modelLineNumber); + const lineWithInjections = getLineTokensWithInjections(lineTokens, injectionOptions, injectionOffsets); for (let outputLineIndex = outputLineIdx; outputLineIndex < outputLineIdx + lineCount; outputLineIndex++) { const globalIndex = globalStartIndex + outputLineIndex - outputLineIdx; @@ -247,11 +183,11 @@ class ModelLineProjection implements IModelLineProjection { result[globalIndex] = null; continue; } - result[globalIndex] = this._getViewLineData(lineWithInjections, inlineDecorationsPerOutputLine ? inlineDecorationsPerOutputLine[outputLineIndex] : null, outputLineIndex); + result[globalIndex] = this._getViewLineData(lineWithInjections, lineInlineDecorations ? lineInlineDecorations[outputLineIndex] : null, outputLineIndex); } } - private _getViewLineData(lineWithInjections: LineTokens, inlineDecorations: null | SingleLineInlineDecoration[], outputLineIndex: number): ViewLineData { + private _getViewLineData(lineWithInjections: LineTokens, inlineDecorations: null | InlineDecoration[], outputLineIndex: number): ViewLineData { this._assertVisible(); const lineBreakData = this._projectionData; const deltaStartIndex = (outputLineIndex > 0 ? lineBreakData.wrappedTextIndentLength : 0); @@ -359,7 +295,7 @@ class IdentityModelLineProjection implements IModelLineProjection { return model.getLineMaxColumn(modelLineNumber); } - public getViewLineData(model: ISimpleModel, modelLineNumber: number, _outputLineIndex: number): ViewLineData { + public getViewLineData(model: ISimpleModel, modelLineNumber: number, _outputLineIndex: number, _baseViewLineNumber: number): ViewLineData { const lineTokens = model.tokenization.getLineTokens(modelLineNumber); const lineContent = lineTokens.getLineContent(); return new ViewLineData( @@ -373,12 +309,12 @@ class IdentityModelLineProjection implements IModelLineProjection { ); } - public getViewLinesData(model: ISimpleModel, modelLineNumber: number, _fromOuputLineIndex: number, _toOutputLineIndex: number, globalStartIndex: number, needed: boolean[], result: Array): void { + public getViewLinesData(model: ISimpleModel, modelLineNumber: number, _fromOuputLineIndex: number, _toOutputLineIndex: number, _baseViewLineNumber: number, globalStartIndex: number, needed: boolean[], result: Array): void { if (!needed[globalStartIndex]) { result[globalStartIndex] = null; return; } - result[globalStartIndex] = this.getViewLineData(model, modelLineNumber, 0); + result[globalStartIndex] = this.getViewLineData(model, modelLineNumber, 0, _baseViewLineNumber); } public getModelColumnOfViewPosition(_outputLineIndex: number, outputColumn: number): number { @@ -445,11 +381,11 @@ class HiddenModelLineProjection implements IModelLineProjection { throw new Error('Not supported'); } - public getViewLineData(_model: ISimpleModel, _modelLineNumber: number, _outputLineIndex: number): ViewLineData { + public getViewLineData(_model: ISimpleModel, _modelLineNumber: number, _outputLineIndex: number, _baseViewLineNumber: number): ViewLineData { throw new Error('Not supported'); } - public getViewLinesData(_model: ISimpleModel, _modelLineNumber: number, _fromOuputLineIndex: number, _toOutputLineIndex: number, _globalStartIndex: number, _needed: boolean[], _result: ViewLineData[]): void { + public getViewLinesData(_model: ISimpleModel, _modelLineNumber: number, _fromOuputLineIndex: number, _toOutputLineIndex: number, _baseViewLineNumber: number, _globalStartIndex: number, _needed: boolean[], _result: ViewLineData[]): void { throw new Error('Not supported'); } diff --git a/src/vs/editor/common/viewModel/viewModelDecorations.ts b/src/vs/editor/common/viewModel/viewModelDecorations.ts index 8b4f52f96bf38..8ccf44be105b4 100644 --- a/src/vs/editor/common/viewModel/viewModelDecorations.ts +++ b/src/vs/editor/common/viewModel/viewModelDecorations.ts @@ -4,54 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import { IDisposable } from '../../../base/common/lifecycle.js'; -import { Position } from '../core/position.js'; import { Range } from '../core/range.js'; import { IEditorConfiguration } from '../config/editorConfiguration.js'; -import { IModelDecoration, ITextModel, PositionAffinity } from '../model.js'; +import { ITextModel } from '../model.js'; import { IViewModelLines } from './viewModelLines.js'; -import { filterFontDecorations, filterValidationDecorations } from '../config/editorOptions.js'; -import { isModelDecorationVisible, ViewModelDecoration } from './viewModelDecoration.js'; -import { InlineDecoration, InlineDecorationType } from './inlineDecorations.js'; +import { ViewModelDecoration } from './viewModelDecoration.js'; +import { IViewDecorationsCollection, IInlineModelDecorationsComputerContext, InlineModelDecorationsComputer } from './inlineDecorations.js'; import { ICoordinatesConverter } from '../coordinatesConverter.js'; - -/** - * A collection of decorations in a range of lines. - */ -export interface IViewDecorationsCollection { - /** - * decorations in the range of lines (ungrouped). - */ - readonly decorations: ViewModelDecoration[]; - /** - * inline decorations (grouped by each line in the range of lines). - */ - readonly inlineDecorations: InlineDecoration[][]; - /** - * Whether the decorations affect the fonts. - */ - readonly hasVariableFonts: boolean[]; -} +import { filterFontDecorations, filterValidationDecorations } from '../config/editorOptions.js'; export class ViewModelDecorations implements IDisposable { private readonly editorId: number; - private readonly model: ITextModel; private readonly configuration: IEditorConfiguration; private readonly _linesCollection: IViewModelLines; - private readonly _coordinatesConverter: ICoordinatesConverter; - private _decorationsCache: { [decorationId: string]: ViewModelDecoration }; + private readonly _inlineDecorationsComputer: InlineModelDecorationsComputer; private _cachedModelDecorationsResolver: IViewDecorationsCollection | null; private _cachedModelDecorationsResolverViewRange: Range | null; constructor(editorId: number, model: ITextModel, configuration: IEditorConfiguration, linesCollection: IViewModelLines, coordinatesConverter: ICoordinatesConverter) { this.editorId = editorId; - this.model = model; this.configuration = configuration; this._linesCollection = linesCollection; - this._coordinatesConverter = coordinatesConverter; - this._decorationsCache = Object.create(null); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: (viewRange: Range, onlyMinimapDecorations: boolean, onlyMarginDecorations: boolean) => this._linesCollection.getDecorationsInRange(viewRange, this.editorId, filterValidationDecorations(this.configuration.options), filterFontDecorations(this.configuration.options), onlyMinimapDecorations, onlyMarginDecorations) + }; + this._inlineDecorationsComputer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); this._cachedModelDecorationsResolver = null; this._cachedModelDecorationsResolverViewRange = null; } @@ -62,57 +42,35 @@ export class ViewModelDecorations implements IDisposable { } public dispose(): void { - this._decorationsCache = Object.create(null); + this._inlineDecorationsComputer.reset(); this._clearCachedModelDecorationsResolver(); } public reset(): void { - this._decorationsCache = Object.create(null); + this._inlineDecorationsComputer.reset(); this._clearCachedModelDecorationsResolver(); } public onModelDecorationsChanged(): void { - this._decorationsCache = Object.create(null); + this._inlineDecorationsComputer.onModelDecorationsChanged(); this._clearCachedModelDecorationsResolver(); } public onLineMappingChanged(): void { - this._decorationsCache = Object.create(null); + this._inlineDecorationsComputer.onLineMappingChanged(); this._clearCachedModelDecorationsResolver(); } - private _getOrCreateViewModelDecoration(modelDecoration: IModelDecoration): ViewModelDecoration { - const id = modelDecoration.id; - let r = this._decorationsCache[id]; - if (!r) { - const modelRange = modelDecoration.range; - const options = modelDecoration.options; - let viewRange: Range; - if (options.isWholeLine) { - const start = this._coordinatesConverter.convertModelPositionToViewPosition(new Position(modelRange.startLineNumber, 1), PositionAffinity.Left, false, true); - const end = this._coordinatesConverter.convertModelPositionToViewPosition(new Position(modelRange.endLineNumber, this.model.getLineMaxColumn(modelRange.endLineNumber)), PositionAffinity.Right); - viewRange = new Range(start.lineNumber, start.column, end.lineNumber, end.column); - } else { - // For backwards compatibility reasons, we want injected text before any decoration. - // Thus, move decorations to the right. - viewRange = this._coordinatesConverter.convertModelRangeToViewRange(modelRange, PositionAffinity.Right); - } - r = new ViewModelDecoration(viewRange, options); - this._decorationsCache[id] = r; - } - return r; - } - public getMinimapDecorationsInRange(range: Range): ViewModelDecoration[] { - return this._getDecorationsInRange(range, true, false).decorations; + return this._inlineDecorationsComputer.getDecorations(range, true, false).decorations; } public getDecorationsViewportData(viewRange: Range): IViewDecorationsCollection { let cacheIsValid = (this._cachedModelDecorationsResolver !== null); cacheIsValid = cacheIsValid && (viewRange.equalsRange(this._cachedModelDecorationsResolverViewRange)); if (!cacheIsValid) { - this._cachedModelDecorationsResolver = this._getDecorationsInRange(viewRange, false, false); + this._cachedModelDecorationsResolver = this._inlineDecorationsComputer.getDecorations(viewRange, false, false); this._cachedModelDecorationsResolverViewRange = viewRange; } return this._cachedModelDecorationsResolver!; @@ -120,79 +78,6 @@ export class ViewModelDecorations implements IDisposable { public getDecorationsOnLine(lineNumber: number, onlyMinimapDecorations: boolean = false, onlyMarginDecorations: boolean = false): IViewDecorationsCollection { const range = new Range(lineNumber, this._linesCollection.getViewLineMinColumn(lineNumber), lineNumber, this._linesCollection.getViewLineMaxColumn(lineNumber)); - return this._getDecorationsInRange(range, onlyMinimapDecorations, onlyMarginDecorations); - } - - private _getDecorationsInRange(viewRange: Range, onlyMinimapDecorations: boolean, onlyMarginDecorations: boolean): IViewDecorationsCollection { - const modelDecorations = this._linesCollection.getDecorationsInRange(viewRange, this.editorId, filterValidationDecorations(this.configuration.options), filterFontDecorations(this.configuration.options), onlyMinimapDecorations, onlyMarginDecorations); - const startLineNumber = viewRange.startLineNumber; - const endLineNumber = viewRange.endLineNumber; - - const decorationsInViewport: ViewModelDecoration[] = []; - let decorationsInViewportLen = 0; - const inlineDecorations: InlineDecoration[][] = []; - const hasVariableFonts: boolean[] = []; - for (let j = startLineNumber; j <= endLineNumber; j++) { - inlineDecorations[j - startLineNumber] = []; - hasVariableFonts[j - startLineNumber] = false; - } - - for (let i = 0, len = modelDecorations.length; i < len; i++) { - const modelDecoration = modelDecorations[i]; - const decorationOptions = modelDecoration.options; - - if (!isModelDecorationVisible(this.model, modelDecoration)) { - continue; - } - - const viewModelDecoration = this._getOrCreateViewModelDecoration(modelDecoration); - const viewRange = viewModelDecoration.range; - - decorationsInViewport[decorationsInViewportLen++] = viewModelDecoration; - - if (decorationOptions.inlineClassName) { - const inlineDecoration = new InlineDecoration(viewRange, decorationOptions.inlineClassName, decorationOptions.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular); - const intersectedStartLineNumber = Math.max(startLineNumber, viewRange.startLineNumber); - const intersectedEndLineNumber = Math.min(endLineNumber, viewRange.endLineNumber); - for (let j = intersectedStartLineNumber; j <= intersectedEndLineNumber; j++) { - inlineDecorations[j - startLineNumber].push(inlineDecoration); - if (decorationOptions.affectsFont) { - hasVariableFonts[j - startLineNumber] = true; - } - } - } - if (decorationOptions.beforeContentClassName) { - if (startLineNumber <= viewRange.startLineNumber && viewRange.startLineNumber <= endLineNumber) { - const inlineDecoration = new InlineDecoration( - new Range(viewRange.startLineNumber, viewRange.startColumn, viewRange.startLineNumber, viewRange.startColumn), - decorationOptions.beforeContentClassName, - InlineDecorationType.Before - ); - inlineDecorations[viewRange.startLineNumber - startLineNumber].push(inlineDecoration); - if (decorationOptions.affectsFont) { - hasVariableFonts[viewRange.startLineNumber - startLineNumber] = true; - } - } - } - if (decorationOptions.afterContentClassName) { - if (startLineNumber <= viewRange.endLineNumber && viewRange.endLineNumber <= endLineNumber) { - const inlineDecoration = new InlineDecoration( - new Range(viewRange.endLineNumber, viewRange.endColumn, viewRange.endLineNumber, viewRange.endColumn), - decorationOptions.afterContentClassName, - InlineDecorationType.After - ); - inlineDecorations[viewRange.endLineNumber - startLineNumber].push(inlineDecoration); - if (decorationOptions.affectsFont) { - hasVariableFonts[viewRange.endLineNumber - startLineNumber] = true; - } - } - } - } - - return { - decorations: decorationsInViewport, - inlineDecorations: inlineDecorations, - hasVariableFonts - }; + return this._inlineDecorationsComputer.getDecorations(range, onlyMinimapDecorations, onlyMarginDecorations); } } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index db795ac7371d1..299fb151446f4 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -886,9 +886,7 @@ export class ViewModel extends Disposable implements IViewModel { if (lineData.inlineDecorations) { inlineDecorations = [ ...inlineDecorations, - ...lineData.inlineDecorations.map(d => - d.toInlineDecoration(lineNumber) - ) + ...lineData.inlineDecorations ]; } diff --git a/src/vs/editor/common/viewModel/viewModelLines.ts b/src/vs/editor/common/viewModel/viewModelLines.ts index dc14410a12d72..b3721760c9d4e 100644 --- a/src/vs/editor/common/viewModel/viewModelLines.ts +++ b/src/vs/editor/common/viewModel/viewModelLines.ts @@ -752,7 +752,8 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { public getViewLineData(viewLineNumber: number): ViewLineData { const info = this.getViewLineInfo(viewLineNumber); - return this.modelLineProjections[info.modelLineNumber - 1].getViewLineData(this.model, info.modelLineNumber, info.modelLineWrappedLineIdx); + const baseViewLineNumber = this.projectedModelLineLineCounts.getPrefixSum(info.modelLineNumber - 1) + 1; + return this.modelLineProjections[info.modelLineNumber - 1].getViewLineData(this.model, info.modelLineNumber, info.modelLineWrappedLineIdx, baseViewLineNumber); } public getViewLinesData(viewStartLineNumber: number, viewEndLineNumber: number, needed: boolean[]): ViewLineData[] { @@ -779,8 +780,8 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { lastLine = true; remainingViewLineCount = viewEndLineNumber - viewLineNumber + 1; } - - line.getViewLinesData(this.model, modelLineIndex + 1, fromViewLineIndex, remainingViewLineCount, viewLineNumber - viewStartLineNumber, needed, result); + const baseViewLineNumber = this.projectedModelLineLineCounts.getPrefixSum(modelLineIndex) + 1; + line.getViewLinesData(this.model, modelLineIndex + 1, fromViewLineIndex, remainingViewLineCount, baseViewLineNumber, viewLineNumber - viewStartLineNumber, needed, result); viewLineNumber += remainingViewLineCount; diff --git a/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts b/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts index ddbb919151a07..05f1deb2aa098 100644 --- a/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts +++ b/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts @@ -915,8 +915,8 @@ suite('SplitLinesCollection', () => { assert.deepStrictEqual( data.map((d) => ({ inlineDecorations: d.inlineDecorations?.map((d) => ({ - startOffset: d.startOffset, - endOffset: d.endOffset, + startOffset: d.range.startColumn - 1, + endOffset: d.range.endColumn - 1, })), })), [ diff --git a/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts b/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts new file mode 100644 index 0000000000000..efc948f669e3c --- /dev/null +++ b/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts @@ -0,0 +1,491 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../../../base/test/common/utils.js'; +import { Range } from '../../../common/core/range.js'; +import { IModelDecoration, IModelDecorationOptions, InjectedTextOptions } from '../../../common/model.js'; +import { InlineDecoration, InlineDecorationType, InlineModelDecorationsComputer, IInlineModelDecorationsComputerContext, InjectedTextInlineDecorationsComputer, IInjectedTextInlineDecorationsComputerContext } from '../../../common/viewModel/inlineDecorations.js'; +import { createTextModel } from '../testTextModel.js'; +import { IdentityCoordinatesConverter } from '../../../common/coordinatesConverter.js'; + +function createModelDecoration(id: string, range: Range, options: IModelDecorationOptions): IModelDecoration { + return { + id, + ownerId: 0, + range, + options + }; +} + +suite('InlineModelDecorationsComputer', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('no decorations', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.deepStrictEqual(result, { + decorations: [], + inlineDecorations: [[]], + hasVariableFonts: [false] + }); + model.dispose(); + }); + + test('inline class name decoration on a single line', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 1, 1, 6), { + description: 'test', + inlineClassName: 'test-class' + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.strictEqual(result.decorations.length, 1); + assert.deepStrictEqual(result.inlineDecorations, [ + [new InlineDecoration(new Range(1, 1, 1, 6), 'test-class', InlineDecorationType.Regular)] + ]); + assert.deepStrictEqual(result.hasVariableFonts, [false]); + model.dispose(); + }); + + test('inlineClassName with affectsLetterSpacing', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 1, 1, 6), { + description: 'test', + inlineClassName: 'test-class', + inlineClassNameAffectsLetterSpacing: true + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.deepStrictEqual(result.inlineDecorations, [ + [new InlineDecoration(new Range(1, 1, 1, 6), 'test-class', InlineDecorationType.RegularAffectingLetterSpacing)] + ]); + model.dispose(); + }); + + test('beforeContentClassName decoration', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 3, 1, 8), { + description: 'test', + beforeContentClassName: 'before-class' + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.deepStrictEqual(result.inlineDecorations, [ + [new InlineDecoration(new Range(1, 3, 1, 3), 'before-class', InlineDecorationType.Before)] + ]); + model.dispose(); + }); + + test('afterContentClassName decoration', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 3, 1, 8), { + description: 'test', + afterContentClassName: 'after-class' + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.deepStrictEqual(result.inlineDecorations, [ + [new InlineDecoration(new Range(1, 8, 1, 8), 'after-class', InlineDecorationType.After)] + ]); + model.dispose(); + }); + + test('all decoration types combined', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 2, 1, 6), { + description: 'test', + inlineClassName: 'inline-class', + beforeContentClassName: 'before-class', + afterContentClassName: 'after-class' + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.deepStrictEqual(result.inlineDecorations, [ + [ + new InlineDecoration(new Range(1, 2, 1, 6), 'inline-class', InlineDecorationType.Regular), + new InlineDecoration(new Range(1, 2, 1, 2), 'before-class', InlineDecorationType.Before), + new InlineDecoration(new Range(1, 6, 1, 6), 'after-class', InlineDecorationType.After), + ] + ]); + model.dispose(); + }); + + test('decoration spanning multiple lines', () => { + const model = createTextModel('line one\nline two\nline three'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 3, 3, 5), { + description: 'test', + inlineClassName: 'multi-line' + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 3, 11), false, false); + const expectedInlineDecoration = new InlineDecoration(new Range(1, 3, 3, 5), 'multi-line', InlineDecorationType.Regular); + assert.deepStrictEqual(result.inlineDecorations, [ + [expectedInlineDecoration], + [expectedInlineDecoration], + [expectedInlineDecoration], + ]); + model.dispose(); + }); + + test('decoration with affectsFont sets hasVariableFonts', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 1, 1, 6), { + description: 'test', + inlineClassName: 'font-class', + affectsFont: true + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.deepStrictEqual(result.hasVariableFonts, [true]); + model.dispose(); + }); + + test('multiple decorations on different lines', () => { + const model = createTextModel('line one\nline two'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 1, 1, 5), { + description: 'test', + inlineClassName: 'class-a' + }), + createModelDecoration('dec2', new Range(2, 1, 2, 5), { + description: 'test', + inlineClassName: 'class-b' + }), + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getDecorations(new Range(1, 1, 2, 9), false, false); + assert.deepStrictEqual(result.inlineDecorations, [ + [new InlineDecoration(new Range(1, 1, 1, 5), 'class-a', InlineDecorationType.Regular)], + [new InlineDecoration(new Range(2, 1, 2, 5), 'class-b', InlineDecorationType.Regular)], + ]); + model.dispose(); + }); + + test('decoration cache is used for same decoration id', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const dec = createModelDecoration('dec1', new Range(1, 1, 1, 6), { + description: 'test', + inlineClassName: 'test-class' + }); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [dec] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result1 = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + const result2 = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.strictEqual(result1.decorations[0], result2.decorations[0]); + model.dispose(); + }); + + test('reset clears decoration cache', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const dec = createModelDecoration('dec1', new Range(1, 1, 1, 6), { + description: 'test', + inlineClassName: 'test-class' + }); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [dec] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result1 = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + computer.reset(); + const result2 = computer.getDecorations(new Range(1, 1, 1, 12), false, false); + assert.notStrictEqual(result1.decorations[0], result2.decorations[0]); + model.dispose(); + }); + + test('getInlineDecorations returns inline decorations for a model line', () => { + const model = createTextModel('hello world'); + const coordinatesConverter = new IdentityCoordinatesConverter(model); + const context: IInlineModelDecorationsComputerContext = { + getModelDecorations: () => [ + createModelDecoration('dec1', new Range(1, 1, 1, 6), { + description: 'test', + inlineClassName: 'test-class' + }) + ] + }; + const computer = new InlineModelDecorationsComputer(context, model, coordinatesConverter); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 1, 1, 6), 'test-class', InlineDecorationType.Regular)] + ]); + model.dispose(); + }); +}); + +suite('InjectedTextInlineDecorationsComputer', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('no injections returns empty', () => { + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => null, + getInjectionOffsets: () => null, + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, []); + }); + + test('single injection with inlineClassName on a single output line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: 'injected', inlineClassName: 'injected-class' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [18], // 10 (original) + 8 (injected) + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 6, 1, 14), 'injected-class', InlineDecorationType.Regular)] + ]); + }); + + test('injection without inlineClassName produces no inline decorations', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: 'injected' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [18], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [] // empty - no inlineClassName + ]); + }); + + test('injection with inlineClassNameAffectsLetterSpacing', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: 'abc', inlineClassName: 'ls-class', inlineClassNameAffectsLetterSpacing: true } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [13], // 10 + 3 + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 1, 1, 4), 'ls-class', InlineDecorationType.RegularAffectingLetterSpacing)] + ]); + }); + + test('multiple injections on a single output line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: 'AA', inlineClassName: 'class-a' }, + { content: 'BBB', inlineClassName: 'class-b' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [2, 5], + getBreakOffsets: () => [15], // 10 + 2 + 3 + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [ + new InlineDecoration(new Range(1, 3, 1, 5), 'class-a', InlineDecorationType.Regular), + new InlineDecoration(new Range(1, 8, 1, 11), 'class-b', InlineDecorationType.Regular), + ] + ]); + }); + + test('injection spanning across wrapped lines', () => { + // Original text is 20 chars, injection of 10 chars at offset 8 + // Break offsets split at 15 and 30 (two wrapped lines) + const injectionOptions: InjectedTextOptions[] = [ + { content: '1234567890', inlineClassName: 'injected' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [8], + getBreakOffsets: () => [15, 30], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 5, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + // Injected text starts at offset 8 in the input with injections + // Line 0: [0, 15), injected text occupies [8, 18) -> clipped to [8, 15) + // Line 1: [15, 30), injected text occupies [8, 18) -> clipped to [15, 18) -> relative: [0, 3) + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(5, 9, 5, 16), 'injected', InlineDecorationType.Regular)], + [new InlineDecoration(new Range(6, 1, 6, 4), 'injected', InlineDecorationType.Regular)], + ]); + }); + + test('injection with wrappedTextIndentLength on wrapped lines', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '12345678901234567890', inlineClassName: 'injected' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [15, 30], + getWrappedTextIndentLength: () => 4, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + // Line 0 (outputLineIndex 0): no offset, start=0, end=15 -> columns 1 to 16 + // Line 1 (outputLineIndex 1): wrappedTextIndentLength=4, start=4+0=4, end=4+5=9 -> columns 5 to 10 + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 1, 1, 16), 'injected', InlineDecorationType.Regular)], + [new InlineDecoration(new Range(2, 5, 2, 10), 'injected', InlineDecorationType.Regular)], + ]); + }); + + test('injection starting in later wrapped line', () => { + // Injection at offset 20 which is past the first line break + const injectionOptions: InjectedTextOptions[] = [ + { content: 'ab', inlineClassName: 'late-class' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [20], + getBreakOffsets: () => [15, 32], // 30 + 2 + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + // Line 0: [0, 15) -> injection at offset 20 is past this line -> empty + // Line 1: [15, 32) -> injection at offset 20 -> start=20-15=5, end=22-15=7 -> columns 6 to 8 + assert.deepStrictEqual(result, [ + [], + [new InlineDecoration(new Range(2, 6, 2, 8), 'late-class', InlineDecorationType.Regular)], + ]); + }); + + test('base view line number offsets correctly', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: 'test', inlineClassName: 'test-class' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [14], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 10, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(10, 1, 10, 5), 'test-class', InlineDecorationType.Regular)] + ]); + }); + + test('range uses view line number, not model line number', () => { + // Model line 3 maps to view line 7 (e.g. due to previous lines wrapping). + // The range in the resulting InlineDecoration must use the view line number (7), + // not the model line number (3) that is passed to getInlineDecorations(). + const modelLineNumber = 3; + const baseViewLineNumber = 7; + const injectionOptions: InjectedTextOptions[] = [ + { content: 'ghost', inlineClassName: 'ghost-class' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [15], // 10 (original) + 5 (injected) + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => baseViewLineNumber, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(modelLineNumber); + // The range must reference view line 7, not model line 3 + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(7, 1, 7, 6), 'ghost-class', InlineDecorationType.Regular)] + ]); + }); + + test('range uses view line number on wrapped lines, not model line number', () => { + // Model line 2 wraps into view lines 5 and 6. + // Both output lines must use view line numbers, not model line 2. + const modelLineNumber = 2; + const baseViewLineNumber = 5; + const injectionOptions: InjectedTextOptions[] = [ + { content: '1234567890', inlineClassName: 'wrap-class' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [8, 20], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => baseViewLineNumber, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(modelLineNumber); + // First wrapped line uses view line 5, second uses view line 6 + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(5, 1, 5, 9), 'wrap-class', InlineDecorationType.Regular)], + [new InlineDecoration(new Range(6, 1, 6, 3), 'wrap-class', InlineDecorationType.Regular)], + ]); + }); +}); diff --git a/src/vs/platform/browserView/common/cdp/proxy.ts b/src/vs/platform/browserView/common/cdp/proxy.ts index e11b7b2638986..85dc5f6d52d84 100644 --- a/src/vs/platform/browserView/common/cdp/proxy.ts +++ b/src/vs/platform/browserView/common/cdp/proxy.ts @@ -69,7 +69,19 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { await this.attachToTarget(targetInfo.targetId, true); } }); - this._targets.onDidUnregisterTarget(async ({ targetInfo }) => { + this._targets.onDidUnregisterTarget(({ targetInfo }) => { + // Close any sessions attached to the destroyed target. Snapshot first + // to avoid mutating _sessions while iterating (onClose fires synchronously). + const toDispose: ICDPConnection[] = []; + for (const [, connection] of this._sessions) { + if (this._sessionTargetIds.get(connection) === targetInfo.targetId) { + toDispose.push(connection); + } + } + for (const connection of toDispose) { + connection.dispose(); + } + if (this._discover) { this.sendBrowserEvent('Target.targetDestroyed', { targetId: targetInfo.targetId }); } diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 75240497f8832..b49c50b5fb388 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -3,16 +3,98 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../base/common/event.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IPlaywrightService = createDecorator('playwrightService'); /** * A service for using Playwright to connect to and automate the integrated browser. + * + * Pages must be explicitly tracked via {@link startTrackingPage} (or implicitly via + * {@link openPage}) before they can be interacted with. */ export interface IPlaywrightService { readonly _serviceBrand: undefined; - // TODO@kycutler: define a more specific API. - initialize(): Promise; + /** + * Fires when the set of tracked pages changes. + * The event value is the full list of currently tracked view IDs. + */ + readonly onDidChangeTrackedPages: Event; + + /** + * Start tracking an existing browser view so that agent + * tools can interact with it. + * @param viewId The browser view identifier. + */ + startTrackingPage(viewId: string): Promise; + + /** + * Stop tracking a browser view. + * @param viewId The browser view identifier. + */ + stopTrackingPage(viewId: string): Promise; + + /** + * Whether the given page is currently tracked by the service. + */ + isPageTracked(viewId: string): Promise; + + /** + * Get the list of currently tracked page IDs. + */ + getTrackedPages(): Promise; + + /** + * Opens a new page in the browser and returns its associated view ID. + * The page is automatically added to the tracked pages. + * @param url The URL to open in the new page. + * @returns An object containing the new page's view ID and a summary of its initial state. + */ + openPage(url: string): Promise<{ pageId: string; summary: string }>; + + /** + * Gets a summary of the page's current state, including its DOM and visual representation. + * @param pageId The browser view ID identifying the page to read. + * @returns The summary of the page's current state. + */ + getSummary(pageId: string): Promise; + + /** + * Run a function with access to a Playwright page. + * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. + * @param pageId The browser view ID identifying the page to operate on. + * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. + * @param args Additional arguments to pass to the function after the `page` object. + * @returns The result of the function execution, including a page summary. + */ + invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; + + /** + * Takes a screenshot of the current page viewport and returns it as a VSBuffer. + * @param pageId The browser view ID identifying the page to capture. + * @param selector Optional Playwright selector to capture a specific element instead of the viewport. + * @param fullPage Whether to capture the full scrollable page instead of just the viewport. + * @returns The screenshot image data. + */ + captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise; + + /** + * Responds to a file chooser dialog on the given page. + * @param pageId The browser view ID identifying the page. + * @param files The list of files to select in the file chooser. Empty to dismiss the dialog without selecting files. + * @returns An object with the page summary afterwards. + */ + replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }>; + + /** + * Responds to a dialog (alert, confirm, prompt) on the given page. + * @param pageId The browser view ID identifying the page. + * @param accept Whether to accept or dismiss the dialog. + * @param promptText Optional text to enter into a prompt dialog. + * @returns An object with the page summary afterwards. + */ + replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }>; } diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index efd4d01abf3fb..d7d59c2701889 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -87,6 +87,10 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I async removeView(viewId: string): Promise { const view = this.views.get(viewId); if (view && this.views.delete(viewId)) { + // If no remaining views belong to the view's context, and we don't own the context, remove it from known contexts + if (!this.ownedContextIds.has(view.session.id) && ![...this.views.values()].some(v => v.session.id === view.session.id)) { + this.knownContextIds.delete(view.session.id); + } this._onDidRemoveView.fire({ viewId: view.id }); this._onTargetDestroyed.fire(view); } diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index f19ee20bd94ea..a2b807cf01b27 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -3,24 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; import { IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { PlaywrightTab } from './playwrightTab.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, Page } from 'playwright-core'; /** * Shared-process implementation of {@link IPlaywrightService}. + * + * Creates a {@link PlaywrightPageManager} eagerly on construction to track + * browser views. The Playwright browser connection is lazily initialised + * only when an operation that requires it is called. */ export class PlaywrightService extends Disposable implements IPlaywrightService { declare readonly _serviceBrand: undefined; + private readonly _pages: PlaywrightPageManager; + readonly onDidChangeTrackedPages: Event; + private _browser: Browser | undefined; - private _pages: PlaywrightPageManager | undefined; private _initPromise: Promise | undefined; constructor( @@ -28,13 +37,36 @@ export class PlaywrightService extends Disposable implements IPlaywrightService @ILogService private readonly logService: ILogService, ) { super(); + this._pages = this._register(new PlaywrightPageManager(logService)); + this.onDidChangeTrackedPages = this._pages.onDidChangeTrackedPages; + } + + // --- Page tracking (delegated to manager) --- + + async startTrackingPage(viewId: string): Promise { + return this._pages.startTrackingPage(viewId); + } + + async stopTrackingPage(viewId: string): Promise { + return this._pages.stopTrackingPage(viewId); } + async isPageTracked(viewId: string): Promise { + return this._pages.isPageTracked(viewId); + } + + async getTrackedPages(): Promise { + return this._pages.getTrackedPages(); + } + + // --- Playwright operations (lazy init) --- + /** - * Ensure the Playwright browser connection and page map are initialized. + * Ensure the Playwright browser connection is initialized and the page + * manager is wired up to the browser view group. */ - async initialize(): Promise { - if (this._pages) { + private async initialize(): Promise { + if (this._browser) { return; } @@ -45,7 +77,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this._initPromise = (async () => { try { this.logService.debug('[PlaywrightService] Creating browser view group'); - const group = this._register(await this.browserViewGroupRemoteService.createGroup()); + const group = await this.browserViewGroupRemoteService.createGroup(); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); const playwright = await import('playwright-core'); @@ -57,25 +89,21 @@ export class PlaywrightService extends Disposable implements IPlaywrightService // This can happen if the service was disposed while we were waiting for the connection. In that case, clean up immediately. if (this._initPromise === undefined) { browser.close().catch(() => { /* ignore */ }); + group.dispose(); throw new Error('PlaywrightService was disposed during initialization'); } - const pageManager = this._register(new PlaywrightPageManager(group, browser, this.logService)); - browser.on('disconnected', () => { this.logService.debug('[PlaywrightService] Browser disconnected'); if (this._browser === browser) { - group.dispose(); - pageManager.dispose(); - + this._pages.reset(); this._browser = undefined; - this._pages = undefined; this._initPromise = undefined; } }); + await this._pages.initialize(browser, group); this._browser = browser; - this._pages = pageManager; } catch (e) { this._initPromise = undefined; throw e; @@ -85,6 +113,65 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._initPromise; } + async openPage(url: string): Promise<{ pageId: string; summary: string }> { + await this.initialize(); + const pageId = await this._pages.newPage(url); + const summary = await this._pages.getSummary(pageId); + return { pageId, summary }; + } + + async getSummary(pageId: string): Promise { + await this.initialize(); + return this._pages.getSummary(pageId, true); + } + + async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { + this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); + + try { + await this.initialize(); + + const vm = await import('vm'); + const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + + let result; + try { + result = await this._pages.runAgainstPage(pageId, (page) => fn(page, args)); + } catch (err: unknown) { + result = err instanceof Error ? err.message : String(err); + } + + const summary = await this._pages.getSummary(pageId); + return { result, summary }; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + this.logService.error('[PlaywrightService] Script execution failed:', errorMessage); + throw err; + } + } + + async captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise { + await this.initialize(); + return this._pages.runAgainstPage(pageId, async page => { + const screenshotBuffer = selector + ? await page.locator(selector).screenshot({ type: 'jpeg', quality: 80 }) + : await page.screenshot({ type: 'jpeg', quality: 80, fullPage: fullPage ?? false }); + return VSBuffer.wrap(screenshotBuffer); + }); + } + + async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { + await this.initialize(); + const summary = await this._pages.replyToFileChooser(pageId, files); + return { summary }; + } + + async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise<{ summary: string }> { + await this.initialize(); + const summary = await this._pages.replyToDialog(pageId, accept, promptText); + return { summary }; + } + override dispose(): void { if (this._browser) { this._browser.close().catch(() => { /* ignore */ }); @@ -96,25 +183,34 @@ export class PlaywrightService extends Disposable implements IPlaywrightService } /** - * Correlates browser view IDs with Playwright {@link Page} instances. + * Manages page tracking and correlates browser view IDs with Playwright + * {@link Page} instances. * - * When a browser view is added to a group, two asynchronous events follow - * through independent channels: + * Created eagerly by {@link PlaywrightService} and operates in two phases: * - * 1. The group fires {@link IBrowserViewGroup.onDidAddView} (via IPC). - * 2. Playwright receives a CDP `Target.targetCreated` event (via WebSocket) - * and fires a `page` event on the matching {@link BrowserContext}. - * - * This class pairs the two event streams by FIFO ordering: the first view-ID - * received is matched with the first page event received. + * 1. **Before initialization** - tracks which pages are added/removed but + * cannot resolve Playwright {@link Page} objects. + * 2. **After {@link initialize}** - proxies add/remove calls to the + * {@link IBrowserViewGroup} and pairs view IDs with Playwright pages + * via FIFO matching of the group's IPC events and Playwright's CDP events. * * A periodic scan handles the case where Playwright creates a new * {@link BrowserContext} for a target whose session was previously unknown. */ class PlaywrightPageManager extends Disposable { + // --- Page tracking --- + + private readonly _trackedPages = new Set(); + + private readonly _onDidChangeTrackedPages = this._register(new Emitter()); + readonly onDidChangeTrackedPages: Event = this._onDidChangeTrackedPages.event; + + // --- Page matching --- + private readonly _viewIdToPage = new Map(); private readonly _pageToViewId = new WeakMap(); + private readonly _tabs = new WeakMap(); /** View IDs received from the group but not yet matched with a page. */ private _viewIdQueue: Array<{ @@ -131,32 +227,163 @@ class PlaywrightPageManager extends Disposable { private readonly _watchedContexts = new WeakSet(); private _scanTimer: ReturnType | undefined; + // --- Initialized state --- + + private readonly _initStore = this._register(new DisposableStore()); + private _group: IBrowserViewGroup | undefined; + private _browser: Browser | undefined; + constructor( - private readonly _group: IBrowserViewGroup, - private readonly _browser: Browser, private readonly logService: ILogService, ) { super(); + } - this._register(_group.onDidAddView(e => this.onViewAdded(e.viewId))); - this._register(_group.onDidRemoveView(e => this.onViewRemoved(e.viewId))); - this.scanForNewContexts(); + // --- Public: page tracking --- + + isPageTracked(viewId: string): boolean { + return this._trackedPages.has(viewId); + } + + getTrackedPages(): readonly string[] { + return [...this._trackedPages]; } + async startTrackingPage(viewId: string): Promise { + if (this._trackedPages.has(viewId)) { + return; + } + + this._trackedPages.add(viewId); + this._fireTrackedPagesChanged(); + + if (this._group) { + await this._addPageToGroup(viewId); + } + } + + async stopTrackingPage(viewId: string): Promise { + if (!this._trackedPages.has(viewId)) { + return; + } + + this._trackedPages.delete(viewId); + this._fireTrackedPagesChanged(); + + if (this._group) { + await this._removePageFromGroup(viewId); + } + } + + // --- Public: Playwright operations (require initialization) --- + /** - * Create a new page in the browser and return its associated page and view ID. + * Create a new page in the browser and return its associated page ID. + * The page is automatically added to the tracked set. */ - async newPage(): Promise<{ viewId: string; page: Page }> { + async newPage(url: string): Promise { + if (!this._browser) { + throw new Error('PlaywrightPageManager has not been initialized'); + } + const page = await this._browser.newPage(); const viewId = await this.onPageAdded(page); - return { viewId, page }; + this._trackedPages.add(viewId); + this._fireTrackedPagesChanged(); + + await page.goto(url, { waitUntil: 'domcontentloaded' }); + + return viewId; + } + + async runAgainstPage(pageId: string, callback: (page: Page) => T | Promise): Promise { + const page = await this.getPage(pageId); + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to execute function against page'); + } + return tab.safeRunAgainstPage(async () => callback(page)); + } + + async getSummary(pageId: string, full = false): Promise { + const page = await this.getPage(pageId); + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to get page summary'); + } + return tab.getSummary(full); + } + + async replyToDialog(pageId: string, accept: boolean, promptText?: string): Promise { + const page = await this.getPage(pageId); + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to reply to dialog'); + } + await tab.replyToDialog(accept, promptText); + return tab.getSummary(); + } + + async replyToFileChooser(pageId: string, files: string[]): Promise { + const page = await this.getPage(pageId); + const tab = this._tabs.get(page); + if (!tab) { + throw new Error('Failed to reply to file chooser'); + } + await tab.replyToFileChooser(files); + return tab.getSummary(); + } + + // --- Initialization --- + + /** + * Wire up the manager to a browser and group. Replays any pages that + * were tracked before initialization. + */ + async initialize(browser: Browser, group: IBrowserViewGroup): Promise { + this._initStore.clear(); + + this._browser = browser; + this._group = group; + + this._initStore.add(group); + this._initStore.add(group.onDidAddView(e => this.onViewAdded(e.viewId))); + this._initStore.add(group.onDidRemoveView(e => this.onViewRemoved(e.viewId))); + + this.scanForNewContexts(); + + // Eagerly connect any pages that were tracked before initialization. + await Promise.all( + [...this._trackedPages].map(viewId => this._addPageToGroup(viewId)) + ); } /** - * Explicitly add an existing browser view to the CDP group. + * Clear initialized state but preserve tracked pages so the manager + * can be re-initialized with a new browser and group. */ - async addPage(viewId: string): Promise { + reset(): void { + this._initStore.clear(); + this._browser = undefined; + this._group = undefined; + + this.stopScanning(); + this._viewIdToPage.clear(); + + for (const { page } of this._viewIdQueue) { + page.error(new Error('PlaywrightPageManager reset')); + } + for (const { viewId } of this._pageQueue) { + viewId.error(new Error('PlaywrightPageManager reset')); + } + this._viewIdQueue = []; + this._pageQueue = []; + } + + // --- Private: group proxy --- + + private async _addPageToGroup(viewId: string): Promise { if (this._viewIdToPage.has(viewId)) { return; } @@ -164,36 +391,40 @@ class PlaywrightPageManager extends Disposable { return; } - // ensure the viewId is queued so we can immediately fetch the promise via getPage(). + // Ensure the viewId is queued so we can immediately fetch the promise via getPage(). this.onViewAdded(viewId); try { - await this._group.addView(viewId); + await this._group!.addView(viewId); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightPageMap] Failed to add view:', errorMessage); + this.logService.error('[PlaywrightPageManager] Failed to add view:', errorMessage); this.onViewRemoved(viewId); } } - /** - * Remove a browser view from the CDP group. - */ - async removePage(viewId: string): Promise { + private async _removePageFromGroup(viewId: string): Promise { this.onViewRemoved(viewId); try { - await this._group.removeView(viewId); + await this._group!.removeView(viewId); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightPageMap] Failed to remove view:', errorMessage); + this.logService.error('[PlaywrightPageManager] Failed to remove view:', errorMessage); } } + private _fireTrackedPagesChanged(): void { + this._onDidChangeTrackedPages.fire([...this._trackedPages]); + } + + // --- Page matching (view ↔ page pairing) --- + /** - * Get the Playwright {@link Page} for a browser view that has already been added. - * Throws if the view has not been added. + * Get the Playwright {@link Page} for a browser view. + * If the view is tracked but not yet connected, it is added to the group + * automatically. Throws if the view has not been added. */ - async getPage(viewId: string): Promise { + private async getPage(viewId: string): Promise { const resolved = this._viewIdToPage.get(viewId); if (resolved) { return resolved; @@ -203,6 +434,11 @@ class PlaywrightPageManager extends Disposable { return queued.page.p; } + if (this._trackedPages.has(viewId) && this._group) { + await this._addPageToGroup(viewId); + return this.getPage(viewId); + } + throw new Error(`Page "${viewId}" has not been added to the Playwright service`); } @@ -259,6 +495,8 @@ class PlaywrightPageManager extends Disposable { this.onContextAdded(page.context()); page.once('close', () => this.onPageRemoved(page)); + page.setDefaultTimeout(10000); + this._tabs.set(page, new PlaywrightTab(page)); const deferred = new DeferredPromise(); const timeout = setTimeout(() => deferred.error(new Error(`Timed out waiting for browser view`)), timeoutMs); @@ -317,7 +555,7 @@ class PlaywrightPageManager extends Disposable { viewIdItem.page.complete(pageItem.page); pageItem.viewId.complete(viewIdItem.viewId); - this.logService.debug(`[PlaywrightPageMap] Matched view ${viewIdItem.viewId} → page`); + this.logService.debug(`[PlaywrightPageManager] Matched view ${viewIdItem.viewId} → page`); } if (this._viewIdQueue.length === 0) { @@ -332,6 +570,9 @@ class PlaywrightPageManager extends Disposable { * Also processes any existing pages in newly discovered contexts. */ private scanForNewContexts(): void { + if (!this._browser) { + return; + } for (const context of this._browser.contexts()) { this.onContextAdded(context); } @@ -353,10 +594,10 @@ class PlaywrightPageManager extends Disposable { override dispose(): void { this.stopScanning(); for (const { page } of this._viewIdQueue) { - page.error(new Error('PlaywrightPageMap disposed')); + page.error(new Error('PlaywrightPageManager disposed')); } for (const { viewId } of this._pageQueue) { - viewId.error(new Error('PlaywrightPageMap disposed')); + viewId.error(new Error('PlaywrightPageManager disposed')); } this._viewIdQueue = []; this._pageQueue = []; diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts new file mode 100644 index 0000000000000..231daf0fba047 --- /dev/null +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// eslint-disable-next-line local/code-import-patterns +import type * as playwright from 'playwright-core'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; + +declare module 'playwright-core' { + interface Page { + // A hidden Playwright method that returns an AI-friendly snapshot of the page. + _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; + } +} + +/** + * Wrapper around a Playwright page that tracks additional state like active dialogs and recent console messages, + * and can produce a summary of the page's current state for use in tools. + * + * Loosely based on https://github.com/microsoft/playwright/blob/main/packages/playwright/src/mcp/browser/tab.ts. + */ +export class PlaywrightTab { + private _onDialogStateChanged = new Emitter(); + + private _dialog: playwright.Dialog | undefined; + private _fileChooser: playwright.FileChooser | undefined; + private _logs: { type: string; time: number; description: string }[] = []; + private _needsFullSnapshot = false; + + private _initialized: Promise; + + constructor( + /** + * @deprecated prefer accessing the page via safeRunAgainstPage. + * Only use this directly if you are sure it cannot be blocked by dialogs. + */ + private readonly page: playwright.Page + ) { + page.on('console', event => this._handleConsoleMessage(event)) + .on('pageerror', error => this._handlePageError(error)) + .on('requestfailed', request => this._handleRequestFailed(request)) + .on('filechooser', chooser => this._handleFileChooser(chooser)) + .on('dialog', dialog => this._handleDialog(dialog)) + .on('download', download => this._handleDownload(download)); + + this._initialized = this._initialize(); + } + + private async _initialize() { + const messages = await this.page.consoleMessages().catch(() => []); + for (const message of messages) { this._handleConsoleMessage(message); } + const errors = await this.page.pageErrors().catch(() => []); + for (const error of errors) { this._handlePageError(error); } + } + + private _handleDialog(dialog: playwright.Dialog) { + this._dialog = dialog; + // Playwright doesn't give us an event for when a dialog is closed, so we run a no-op script to know when it closes. + this.page.waitForFunction(() => true, undefined, { timeout: 0 }).then(() => { + if (this._dialog === dialog) { + this._dialog = undefined; + this._onDialogStateChanged.fire(); + } + }); + this._onDialogStateChanged.fire(); + } + + async replyToDialog(accept?: boolean, promptText?: string) { + if (!this._dialog) { + throw new Error('No active dialog to respond to'); + } + const dialog = this._dialog; + this._dialog = undefined; + this._onDialogStateChanged.fire(); + await this.safeRunAgainstPage(async () => { + if (accept) { + await dialog.accept(promptText); + } else { + await dialog.dismiss(); + } + }); + } + + private _handleFileChooser(chooser: playwright.FileChooser) { + this._fileChooser = chooser; + } + + async replyToFileChooser(files: string[]) { + if (!this._fileChooser) { + throw new Error('No active file chooser to respond to'); + } + const chooser = this._fileChooser; + this._fileChooser = undefined; + await this.safeRunAgainstPage(() => chooser.setFiles(files)); + } + + private async _handleDownload(download: playwright.Download) { + this._logs.push({ type: 'download', time: Date.now(), description: `${download.suggestedFilename()}` }); + } + + private _handleRequestFailed(request: playwright.Request) { + const timing = request.timing(); + this._logs.push({ type: 'requestFailed', time: timing.responseEnd + timing.startTime, description: `${request.method()} request to ${request.url()} failed: "${request.failure()?.errorText}"` }); + } + + private _handleConsoleMessage(message: playwright.ConsoleMessage) { + if (message.type() === 'error' || message.type() === 'warning') { + this._logs.push({ type: 'console', time: message.timestamp(), description: `[${message.type()}] ${message.text()}` }); + } + } + + private _handlePageError(error: Error) { + this._logs.push({ type: 'pageError', time: Date.now(), description: error.stack ?? error.message }); + } + + /** + * Run a callback against the page and wait for it to complete. + * Because dialogs pause the page, execution races against any dialog that opens -- if a dialog + * appears before the callback finishes, the method throws so the caller can surface it to the agent. + */ + async safeRunAgainstPage(action: (page: playwright.Page, token: CancellationToken) => Promise): Promise { + if (this._dialog) { + throw new Error(`Cannot perform action while a dialog is open`); + } + + let actionDidComplete = false; + let result: T | void; + const dialogOpened = Event.toPromise(this._onDialogStateChanged.event); + const actionCompleted = createCancelablePromise(async (token) => { + result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); + actionDidComplete = true; + }); + + return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { + if (!actionDidComplete) { + // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. + throw new Error('Action was interrupted by a dialog'); + } + return result!; + }); + } + + async getSummary(full = this._needsFullSnapshot): Promise { + await this._initialized; + + if (full && this._needsFullSnapshot) { + this._needsFullSnapshot = false; + } + + const snapshotFromPage = await this.safeRunAgainstPage((page) => page._snapshotForAI({ track: 'response' })).catch(() => { + this._needsFullSnapshot = true; + return undefined; + }); + const title = await this.safeRunAgainstPage((page) => page.title()).catch(() => ''); + + const logs = this._logs; + this._logs = []; + + const snapshot = (full ? snapshotFromPage?.full : snapshotFromPage?.incremental ?? snapshotFromPage?.full)?.trim() ?? ''; + + return [ + ...(title ? [`Page Title: ${title}`] : []), + `URL: ${this.page.url()}`, + ...(this._dialog ? [`Active ${this._dialog.type()} dialog: "${this._dialog.message()}"`] : []), + ...(this._fileChooser ? [`Active file chooser dialog`] : []), + ...(logs.length > 0 ? [ + `Recent events:`, + ...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`) + ] : []), + ...(snapshot ? ['Snapshot:', snapshot] : []) + ].join('\n'); + } + + private async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { + const requests: playwright.Request[] = []; + + const requestListener = (request: playwright.Request) => requests.push(request); + const disposeListeners = () => { + this.page.off('request', requestListener); + }; + this.page.on('request', requestListener); + + let result: T; + try { + result = await callback(token); + } finally { + disposeListeners(); + } + + const requestedNavigation = requests.some(request => request.isNavigationRequest()); + if (requestedNavigation) { + await this.page.mainFrame().waitForLoadState('load', { timeout: 10000 }).catch(() => { }); + return result; + } + + const promises: Promise[] = []; + for (const request of requests) { + if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); } + else { promises.push(request.response().catch(() => { })); } + } + const timeout = new Promise(resolve => setTimeout(resolve, 5000)); + await Promise.race([Promise.all(promises), timeout]); + + return result; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index f00e7f42abd7c..7744e54a3dacf 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -37,6 +37,9 @@ export class BranchPicker extends Disposable { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; + private readonly _onDidChangeLoading = this._register(new Emitter()); + readonly onDidChangeLoading: Event = this._onDidChangeLoading.event; + private readonly _renderDisposables = this._register(new DisposableStore()); private _slotElement: HTMLElement | undefined; private _triggerElement: HTMLElement | undefined; @@ -68,26 +71,32 @@ export class BranchPicker extends Disposable { if (!repository) { this._newSession?.setBranch(undefined); + this._setLoading(false); this._updateTriggerLabel(); return; } - const refs = await repository.getRefs({ pattern: 'refs/heads' }); - this._branches = refs - .map(ref => ref.name) - .filter((name): name is string => !!name) - .filter(name => !name.includes(COPILOT_WORKTREE_PATTERN)); - - // Select active branch, main, master, or the first branch by default - const defaultBranch = this._branches.find(b => b === repository.state.get().HEAD?.name) - ?? this._branches.find(b => b === 'main') - ?? this._branches.find(b => b === 'master') - ?? this._branches[0]; - if (defaultBranch) { - this._selectBranch(defaultBranch); + this._setLoading(true); + + try { + const refs = await repository.getRefs({ pattern: 'refs/heads' }); + this._branches = refs + .map(ref => ref.name) + .filter((name): name is string => !!name) + .filter(name => !name.includes(COPILOT_WORKTREE_PATTERN)); + + // Select active branch, main, master, or the first branch by default + const defaultBranch = this._branches.find(b => b === repository.state.get().HEAD?.name) + ?? this._branches.find(b => b === 'main') + ?? this._branches.find(b => b === 'master') + ?? this._branches[0]; + if (defaultBranch) { + this._selectBranch(defaultBranch); + } + } finally { + this._setLoading(false); + this._updateTriggerLabel(); } - - this._updateTriggerLabel(); } /** @@ -195,4 +204,8 @@ export class BranchPicker extends Disposable { dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); this._slotElement?.classList.toggle('disabled', isDisabled); } + + private _setLoading(loading: boolean): void { + this._onDidChangeLoading.fire(loading); + } } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index a6987292c9b5e..dc17c4d8727bc 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -309,6 +309,33 @@ color: var(--vscode-descriptionForeground); } +.sessions-chat-picker-slot.loading .action-label { + opacity: 0.5; + cursor: default; + pointer-events: none; +} + +.sessions-chat-picker-slot.loading .action-label .codicon-chevron-down { + display: none; +} + +.sessions-chat-picker-slot.loading .action-label::after { + content: ''; + display: inline-block; + width: 12px; + height: 12px; + margin-left: 4px; + border: 1.5px solid var(--vscode-descriptionForeground); + border-top-color: transparent; + border-radius: 50%; + animation: sessions-chat-picker-spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes sessions-chat-picker-spin { + to { transform: rotate(360deg); } +} + .sessions-chat-picker-slot .action-label .codicon { font-size: 14px; flex-shrink: 0; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 8710e63bf3dc3..e59a3508c61a5 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -34,7 +34,7 @@ } .sessions-chat-input-area:focus-within { - border-color: var(--vscode-focusBorder); + border-color: var(--vscode-input-border, var(--vscode-contrastBorder, transparent)); } /* Editor */ @@ -57,8 +57,9 @@ .sessions-chat-toolbar { display: flex; align-items: center; - padding: 4px 8px; + padding: 0 6px 6px 6px; gap: 4px; + color: var(--vscode-icon-foreground); } .sessions-chat-toolbar-spacer { @@ -75,52 +76,73 @@ display: flex; align-items: center; gap: 4px; - padding: 3px 8px; + height: 16px; + padding: 3px 6px; border-radius: 4px; font-size: 12px; cursor: pointer; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); } .sessions-chat-model-picker .action-label:hover { background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); } .sessions-chat-model-picker .action-label .codicon { font-size: 12px; } -/* Send button */ +/* Send button - wraps a Button widget */ .sessions-chat-send-button { display: flex; align-items: center; +} + +.sessions-chat-send-button .monaco-button { + display: flex; + align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 22px; + height: 22px; + min-width: 22px; + padding: 0; border-radius: 4px; - cursor: pointer; - color: var(--vscode-descriptionForeground); - background: transparent; - border: none; - outline: none; + color: var(--vscode-icon-foreground); + background: transparent !important; + border: none !important; } -.sessions-chat-send-button:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); +.sessions-chat-send-button .monaco-button:not(.disabled):hover { + background-color: var(--vscode-toolbar-hoverBackground) !important; } -.sessions-chat-send-button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; +.sessions-chat-send-button .monaco-button .codicon { + font-size: 16px; } -.sessions-chat-send-button .codicon { - font-size: 16px; +/* Loading spinner in toolbar */ +.sessions-chat-loading-spinner { + display: none; + width: 12px; + height: 12px; + margin-right: 4px; + border: 1.5px solid var(--vscode-icon-foreground); + border-top-color: transparent; + border-radius: 50%; + animation: sessions-chat-spin 0.8s linear infinite; + opacity: 0.6; + flex-shrink: 0; +} + +.sessions-chat-loading-spinner.visible { + display: block; +} + +@keyframes sessions-chat-spin { + to { transform: rotate(360deg); } } -/* Attach row (above editor, inside input area) */ +/* Attach row (pills only, above editor, inside input area) */ .sessions-chat-attach-row { display: flex; flex-wrap: wrap; @@ -129,16 +151,20 @@ padding: 4px 6px 0 6px; } +.sessions-chat-attach-row:has(.sessions-chat-attached-context:empty) { + display: none; +} + /* Attach context button */ .sessions-chat-attach-button { display: flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 22px; + height: 22px; border-radius: 4px; cursor: pointer; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); background: transparent; border: none; outline: none; @@ -146,7 +172,6 @@ .sessions-chat-attach-button:hover { background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); } .sessions-chat-attach-button:focus-visible { diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index ccb09bd0b06f1..26de8dfa68797 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -10,9 +10,11 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { toAction } from '../../../../base/common/actions.js'; import { Emitter } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; @@ -27,6 +29,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; @@ -89,6 +92,16 @@ class NewChatWidget extends Disposable { private readonly _newSession = this._register(new MutableDisposable()); private readonly _newSessionListener = this._register(new MutableDisposable()); + // Send button + private _sendButton: Button | undefined; + + // Repository loading + private readonly _openRepositoryCts = this._register(new MutableDisposable()); + private _repositoryLoading = false; + private _branchLoading = false; + private _loadingSpinner: HTMLElement | undefined; + private readonly _loadingDelayDisposable = this._register(new MutableDisposable()); + // Welcome part private _pickersContainer: HTMLElement | undefined; private _extensionPickersLeftContainer: HTMLElement | undefined; @@ -116,7 +129,7 @@ class NewChatWidget extends Disposable { @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextMenuService contextMenuService: IContextMenuService, @ILogService private readonly logService: ILogService, - @IHoverService _hoverService: IHoverService, + @IHoverService private readonly hoverService: IHoverService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @IGitService private readonly gitService: IGitService, @@ -135,6 +148,7 @@ class NewChatWidget extends Disposable { const isLocal = target === AgentSessionProviders.Background; this._isolationModePicker.setVisible(isLocal); this._branchPicker.setVisible(isLocal); + this._focusEditor(); })); this._register(this.contextKeyService.onDidChangeContext(e => { @@ -142,6 +156,23 @@ class NewChatWidget extends Disposable { this._renderExtensionPickers(true); } })); + + this._register(this._branchPicker.onDidChangeLoading(loading => { + this._branchLoading = loading; + this._updateInputLoadingState(); + })); + + this._register(this._branchPicker.onDidChange(() => { + this._focusEditor(); + })); + + this._register(this._folderPicker.onDidSelectFolder(() => { + this._focusEditor(); + })); + + this._register(this._isolationModePicker.onDidChange(() => { + this._focusEditor(); + })); } // --- Rendering --- @@ -165,9 +196,8 @@ class NewChatWidget extends Disposable { this._contextAttachments.registerDropTarget(inputArea); this._contextAttachments.registerPasteHandler(inputArea); - // Attachments row (plus button + pills) inside input area, above editor + // Attachments row (pills only) inside input area, above editor const attachRow = dom.append(inputArea, dom.$('.sessions-chat-attach-row')); - this._createAttachButton(attachRow); const attachedContextContainer = dom.append(attachRow, dom.$('.sessions-chat-attached-context')); this._contextAttachments.renderAttachedContext(attachedContextContainer); @@ -198,6 +228,11 @@ class NewChatWidget extends Disposable { // Reveal welcomeElement.classList.add('revealed'); + + // Layout editor after the input slot fade-in animation completes + this._register(dom.addDisposableListener(this._inputSlot, 'animationend', () => { + this._editor?.layout(); + }, { once: true })); } private async _createNewSession(): Promise { @@ -251,20 +286,61 @@ class NewChatWidget extends Disposable { this._syncOptionsFromSession(session.resource); this._renderExtensionPickers(); } + if (changeType === 'disabled') { + this._updateSendButtonState(); + } }); + + this._updateSendButtonState(); } private _openRepository(folderUri: URI): void { + this._openRepositoryCts.value?.cancel(); + const cts = this._openRepositoryCts.value = new CancellationTokenSource(); + + this._repositoryLoading = true; + this._updateInputLoadingState(); + this._branchPicker.setRepository(undefined); + this._isolationModePicker.setRepository(undefined); + this.gitService.openRepository(folderUri).then(repository => { + if (cts.token.isCancellationRequested) { + return; + } + this._repositoryLoading = false; + this._updateInputLoadingState(); this._isolationModePicker.setRepository(repository); this._branchPicker.setRepository(repository); }).catch(e => { + if (cts.token.isCancellationRequested) { + return; + } this.logService.warn(`Failed to open repository at ${folderUri.toString()}`, getErrorMessage(e)); + this._repositoryLoading = false; + this._updateInputLoadingState(); this._isolationModePicker.setRepository(undefined); this._branchPicker.setRepository(undefined); }); } + private _updateInputLoadingState(): void { + const loading = this._repositoryLoading || this._branchLoading; + if (loading) { + if (!this._loadingDelayDisposable.value) { + const timer = setTimeout(() => { + this._loadingDelayDisposable.clear(); + if (this._repositoryLoading || this._branchLoading) { + this._loadingSpinner?.classList.add('visible'); + } + }, 500); + this._loadingDelayDisposable.value = toDisposable(() => clearTimeout(timer)); + } + } else { + this._loadingDelayDisposable.clear(); + this._loadingSpinner?.classList.remove('visible'); + } + } + // --- Editor --- private _createEditor(container: HTMLElement): void { @@ -310,6 +386,14 @@ class NewChatWidget extends Disposable { this._register(this._editor.onDidContentSizeChange(() => { this._editor.layout(); })); + + this._register(this._editor.onDidChangeModelContent(() => { + this._updateSendButtonState(); + })); + } + + private _focusEditor(): void { + this._editor?.focus(); } private _createAttachButton(container: HTMLElement): void { @@ -358,14 +442,22 @@ class NewChatWidget extends Disposable { const modelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); this._createModelPicker(modelPickerContainer); + this._createAttachButton(toolbar); + dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); - const sendButton = dom.append(toolbar, dom.$('.sessions-chat-send-button')); - sendButton.tabIndex = 0; - sendButton.role = 'button'; - sendButton.title = localize('send', "Send"); - dom.append(sendButton, renderIcon(Codicon.send)); - this._register(dom.addDisposableListener(sendButton, dom.EventType.CLICK, () => this._send())); + this._loadingSpinner = dom.append(toolbar, dom.$('.sessions-chat-loading-spinner')); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._loadingSpinner, localize('loading', "Loading..."))); + + const sendButtonContainer = dom.append(toolbar, dom.$('.sessions-chat-send-button')); + const sendButton = this._sendButton = this._register(new Button(sendButtonContainer, { + secondary: true, + title: localize('send', "Send"), + ariaLabel: localize('send', "Send"), + })); + sendButton.icon = Codicon.send; + this._register(sendButton.onDidClick(() => this._send())); + this._updateSendButtonState(); } // --- Model picker --- @@ -376,9 +468,9 @@ class NewChatWidget extends Disposable { setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._currentLanguageModel.set(model, undefined); this._newSession.value?.setModelId(model.identifier); + this._focusEditor(); }, getModels: () => this._getAvailableModels(), - canManageModels: () => true, showCuratedModels: () => false, }; @@ -543,6 +635,7 @@ class NewChatWidget extends Disposable { this._newSession.value?.setOption(optionGroup.id, option); this._renderExtensionPickers(true); + this._focusEditor(); }, getOptionGroup: () => { const groups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); @@ -658,10 +751,18 @@ class NewChatWidget extends Disposable { // --- Send --- + private _updateSendButtonState(): void { + if (!this._sendButton) { + return; + } + const hasText = !!this._editor?.getModel()?.getValue().trim(); + this._sendButton.enabled = hasText && !(this._newSession.value?.disabled ?? true); + } + private _send(): void { const query = this._editor.getModel()?.getValue().trim(); const session = this._newSession.value; - if (!query || !session) { + if (!query || !session || session.disabled) { return; } diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index fa9ea77c5b817..2e601215b535a 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -14,7 +14,7 @@ import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browse import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options'; +export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled'; /** * A new session represents a session being configured before the first @@ -31,6 +31,7 @@ export interface INewSession extends IDisposable { readonly query: string | undefined; readonly attachedContext: IChatRequestVariableEntry[] | undefined; readonly selectedOptions: ReadonlyMap; + readonly disabled: boolean; readonly onDidChange: Event; setRepoUri(uri: URI): void; setIsolationMode(mode: IsolationMode): void; @@ -71,6 +72,15 @@ export class LocalNewSession extends Disposable implements INewSession { get modelId(): string | undefined { return this._modelId; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get disabled(): boolean { + if (!this._repoUri) { + return true; + } + if (this._isolationMode === 'worktree' && !this._branch) { + return true; + } + return false; + } constructor( readonly resource: URI, @@ -90,6 +100,7 @@ export class LocalNewSession extends Disposable implements INewSession { this._isolationMode = 'workspace'; this._branch = undefined; this._onDidChange.fire('repoUri'); + this._onDidChange.fire('disabled'); this.setOption(REPOSITORY_OPTION_ID, uri.fsPath); } @@ -97,6 +108,7 @@ export class LocalNewSession extends Disposable implements INewSession { if (this._isolationMode !== mode) { this._isolationMode = mode; this._onDidChange.fire('isolationMode'); + this._onDidChange.fire('disabled'); this.setOption(ISOLATION_OPTION_ID, mode); } } @@ -105,6 +117,7 @@ export class LocalNewSession extends Disposable implements INewSession { if (this._branch !== branch) { this._branch = branch; this._onDidChange.fire('branch'); + this._onDidChange.fire('disabled'); this.setOption(BRANCH_OPTION_ID, branch ?? ''); } } @@ -158,6 +171,9 @@ export class RemoteNewSession extends Disposable implements INewSession { get modelId(): string | undefined { return this._modelId; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get disabled(): boolean { + return !this._repoUri && !this._hasRepositoryOption(); + } constructor( readonly resource: URI, @@ -181,6 +197,7 @@ export class RemoteNewSession extends Disposable implements INewSession { setRepoUri(uri: URI): void { this._repoUri = uri; this._onDidChange.fire('repoUri'); + this._onDidChange.fire('disabled'); this.setOption('repository', uri.fsPath); } @@ -209,9 +226,14 @@ export class RemoteNewSession extends Disposable implements INewSession { this.selectedOptions.set(optionId, value); } this._onDidChange.fire('options'); + this._onDidChange.fire('disabled'); this.chatSessionsService.notifySessionOptionsChange( this.resource, [{ optionId, value }] ).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err)); } + + private _hasRepositoryOption(): boolean { + return this.selectedOptions.has('repositories'); + } } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 732fa1974e481..8615e08cc6717 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -7,6 +7,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; +import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -90,7 +91,9 @@ export interface IBrowserViewModel extends IDisposable { readonly error: IBrowserViewLoadError | undefined; readonly storageScope: BrowserViewStorageScope; + readonly sharedWithAgent: boolean; + readonly onDidChangeSharedWithAgent: Event; readonly onDidNavigate: Event; readonly onDidChangeLoadingState: Event; readonly onDidChangeFocus: Event; @@ -120,6 +123,7 @@ export interface IBrowserViewModel extends IDisposable { stopFindInPage(keepSelection?: boolean): Promise; getSelectedText(): Promise; clearStorage(): Promise; + setSharedWithAgent(shared: boolean): Promise; } export class BrowserViewModel extends Disposable implements IBrowserViewModel { @@ -135,6 +139,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _canGoForward: boolean = false; private _error: IBrowserViewLoadError | undefined = undefined; private _storageScope: BrowserViewStorageScope = BrowserViewStorageScope.Ephemeral; + private _sharedWithAgent: boolean = false; + + private readonly _onDidChangeSharedWithAgent = this._register(new Emitter()); + readonly onDidChangeSharedWithAgent: Event = this._onDidChangeSharedWithAgent.event; private readonly _onWillDispose = this._register(new Emitter()); readonly onWillDispose: Event = this._onWillDispose.event; @@ -145,7 +153,8 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @IPlaywrightService private readonly playwrightService: IPlaywrightService ) { super(); } @@ -162,6 +171,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get screenshot(): VSBuffer | undefined { return this._screenshot; } get error(): IBrowserViewLoadError | undefined { return this._error; } get storageScope(): BrowserViewStorageScope { return this._storageScope; } + get sharedWithAgent(): boolean { return this._sharedWithAgent; } get onDidNavigate(): Event { return this.browserViewService.onDynamicDidNavigate(this.id); @@ -239,6 +249,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._favicon = state.lastFavicon; this._error = state.lastError; this._storageScope = state.storageScope; + this._sharedWithAgent = await this.playwrightService.isPageTracked(this.id); // Set up state synchronization @@ -277,6 +288,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._register(this.onDidChangeVisibility(({ visible }) => { this._visible = visible; })); + + this._register(this.playwrightService.onDidChangeTrackedPages(ids => { + this._setSharedWithAgent(ids.includes(this.id)); + })); } async layout(bounds: IBrowserViewBounds): Promise { @@ -345,6 +360,21 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.clearStorage(this.id); } + async setSharedWithAgent(shared: boolean): Promise { + if (shared) { + await this.playwrightService.startTrackingPage(this.id); + } else { + await this.playwrightService.stopTrackingPage(this.id); + } + } + + private _setSharedWithAgent(isShared: boolean): void { + if (isShared !== this._sharedWithAgent) { + this._sharedWithAgent = isShared; + this._onDidChangeSharedWithAgent.fire(isShared); + } + } + /** * Log navigation telemetry event */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 58695641a7f53..1efc93fcac096 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -161,6 +161,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private appendedItemCount: number = 0; private isActive: boolean = true; private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = []; + private allThinkingParts: IChatThinkingPart[] = []; private hookCount: number = 0; private singleItemInfo: { element: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null } | undefined; private lazyItems: ILazyItem[] = []; @@ -242,6 +243,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.id = content.id; this.content = content; + this.allThinkingParts.push(content); const configuredMode = this.configurationService.getValue('chat.agent.thinkingStyle') ?? ThinkingDisplayMode.Collapsed; this.fixedScrollingMode = configuredMode === ThinkingDisplayMode.FixedScrolling; @@ -746,17 +748,34 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } - const existingToolTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle; - if (existingToolTitle) { - this.currentTitle = existingToolTitle; - this.content.generatedTitle = existingToolTitle; - super.setTitle(existingToolTitle); + const existingTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle + ?? this.allThinkingParts.find(t => t.generatedTitle)?.generatedTitle; + if (existingTitle) { + this.currentTitle = existingTitle; + this.content.generatedTitle = existingTitle; + this.setGeneratedTitleOnAllParts(existingTitle); + super.setTitle(existingTitle); return; } // case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position - if (this.appendedItemCount === 1 && this.currentThinkingValue.trim() === '' && this.singleItemInfo) { - if (this.restoreSingleItemToOriginalPosition()) { + if (this.appendedItemCount === 1 && this.currentThinkingValue.trim() === '') { + // If singleItemInfo wasn't set (item was lazy/deferred), materialize it now + if (!this.singleItemInfo) { + const lazyItem = this.lazyItems.find(item => item.kind === 'tool' && item.originalParent); + if (lazyItem && lazyItem.kind === 'tool') { + const result = lazyItem.lazy.value; + this.singleItemInfo = { + element: result.domNode, + originalParent: lazyItem.originalParent!, + originalNextSibling: this.domNode + }; + if (result.disposable) { + this._register(result.disposable); + } + } + } + if (this.singleItemInfo && this.restoreSingleItemToOriginalPosition()) { return; } } @@ -766,6 +785,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const title = this.extractedTitles[0]; this.currentTitle = title; this.content.generatedTitle = title; + this.setGeneratedTitleOnAllParts(title); super.setTitle(title); return; } @@ -779,10 +799,13 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.generateTitleViaLLM(); } - private setGeneratedTitleOnToolInvocations(title: string): void { + private setGeneratedTitleOnAllParts(title: string): void { for (const toolInvocation of this.toolInvocations) { toolInvocation.generatedTitle = title; } + for (const thinkingPart of this.allThinkingParts) { + thinkingPart.generatedTitle = title; + } } private async generateTitleViaLLM(): Promise { @@ -942,7 +965,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this._collapseButton.label = generatedTitle; } this.content.generatedTitle = generatedTitle; - this.setGeneratedTitleOnToolInvocations(generatedTitle); + this.setGeneratedTitleOnAllParts(generatedTitle); return; } } catch (error) { @@ -1369,6 +1392,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): return; } this.appendedItemCount++; + this.allThinkingParts.push(content); this.textContainer = $('.chat-thinking-item.markdown-content'); if (content.value) { // Use lazy rendering when collapsed to preserve order with tool items diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index b79ed76c045fc..72c86bd684c90 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2180,8 +2180,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.renderAttachedContext(); }, getModels: () => this.getModels(), - canManageModels: () => !this.getCurrentSessionType(), - showCuratedModels: () => !this.getCurrentSessionType() + showCuratedModels: () => { + const sessionType = this.getCurrentSessionType(); + return !sessionType || sessionType === localChatSessionType; + } }; return this.modelWidget = this.instantiationService.createInstance(EnhancedModelPickerActionItem, action, itemDelegate, pickerOptions); } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 4f61562ed0f3e..a3ace94a9235a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -130,6 +130,7 @@ export function buildModelPickerItems( manageSettingsUrl: string | undefined, commandService: ICommandService, chatEntitlementService: IChatEntitlementService, + showCuratedModels: boolean = true, ): IActionListItem[] { const isPro = isProUser(chatEntitlementService.entitlement); const items: IActionListItem[] = []; @@ -145,167 +146,185 @@ export function buildModelPickerItems( run: () => { } })); } else { - // Collect all available models into lookup maps - const allModelsMap = new Map(); - const modelsByMetadataId = new Map(); - for (const model of models) { - allModelsMap.set(model.identifier, model); - modelsByMetadataId.set(model.metadata.id, model); - } - - const placed = new Set(); - - const markPlaced = (identifierOrId: string, metadataId?: string) => { - placed.add(identifierOrId); - if (metadataId) { - placed.add(metadataId); + if (!showCuratedModels) { + // Flat list: auto first, then all models sorted alphabetically + const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); + if (autoModel) { + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); } - }; - - const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); - - const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { - if (!isPro) { - return 'upgrade'; + const sortedModels = models + .filter(m => m !== autoModel) + .sort((a, b) => { + const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); + return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); + }); + for (const model of sortedModels) { + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect), model)); } - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - return 'update'; + } else { + + // Collect all available models into lookup maps + const allModelsMap = new Map(); + const modelsByMetadataId = new Map(); + for (const model of models) { + allModelsMap.set(model.identifier, model); + modelsByMetadataId.set(model.metadata.id, model); } - return 'admin'; - }; - // --- 1. Auto --- - const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); - if (autoModel) { - markPlaced(autoModel.identifier, autoModel.metadata.id); - items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); - } + const placed = new Set(); - // --- 2. Promoted section (selected + recently used + featured) --- - type PromotedItem = - | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } - | { kind: 'unavailable'; id: string; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; + const markPlaced = (identifierOrId: string, metadataId?: string) => { + placed.add(identifierOrId); + if (metadataId) { + placed.add(metadataId); + } + }; - const promotedItems: PromotedItem[] = []; + const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); - // Try to place a model by id. Returns true if handled. - const tryPlaceModel = (id: string): boolean => { - if (placed.has(id)) { - return false; - } - const model = resolveModel(id); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - const entry = controlModels[model.metadata.id]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', id: model.metadata.id, entry, reason: 'update' }); - } else { - promotedItems.push({ kind: 'available', model }); + const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { + if (!isPro) { + return 'upgrade'; } - return true; - } - if (!model) { - const entry = controlModels[id]; - if (entry && !entry.exists) { - markPlaced(id); - promotedItems.push({ kind: 'unavailable', id, entry, reason: getUnavailableReason(entry) }); - return true; + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + return 'update'; } + return 'admin'; + }; + + // --- 1. Auto --- + const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); + if (autoModel) { + markPlaced(autoModel.identifier, autoModel.metadata.id); + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); } - return false; - }; - // Selected model - if (selectedModelId && selectedModelId !== autoModel?.identifier) { - tryPlaceModel(selectedModelId); - } + // --- 2. Promoted section (selected + recently used + featured) --- + type PromotedItem = + | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } + | { kind: 'unavailable'; id: string; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; - // Recently used models - for (const id of recentModelIds) { - tryPlaceModel(id); - } + const promotedItems: PromotedItem[] = []; - // Featured models from control manifest - for (const [entryId, entry] of Object.entries(controlModels)) { - if (!entry.featured || placed.has(entryId)) { - continue; - } - const model = resolveModel(entryId); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: 'update' }); - } else { - promotedItems.push({ kind: 'available', model }); + // Try to place a model by id. Returns true if handled. + const tryPlaceModel = (id: string): boolean => { + if (placed.has(id)) { + return false; } - } else if (!model && !entry.exists) { - markPlaced(entryId); - promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: getUnavailableReason(entry) }); + const model = resolveModel(id); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + const entry = controlModels[model.metadata.id]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', id: model.metadata.id, entry, reason: 'update' }); + } else { + promotedItems.push({ kind: 'available', model }); + } + return true; + } + if (!model) { + const entry = controlModels[id]; + if (entry && !entry.exists) { + markPlaced(id); + promotedItems.push({ kind: 'unavailable', id, entry, reason: getUnavailableReason(entry) }); + return true; + } + } + return false; + }; + + // Selected model + if (selectedModelId && selectedModelId !== autoModel?.identifier) { + tryPlaceModel(selectedModelId); } - } - // Render promoted section: sorted alphabetically by name - let hasShownActionLink = false; - if (promotedItems.length > 0) { - promotedItems.sort((a, b) => { - const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; - const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; - return aName.localeCompare(bName); - }); + // Recently used models + for (const id of recentModelIds) { + tryPlaceModel(id); + } - for (const item of promotedItems) { - if (item.kind === 'available') { - items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); - } else { - const showActionLink = item.reason === 'upgrade' ? !hasShownActionLink : true; - if (showActionLink && item.reason === 'upgrade') { - hasShownActionLink = true; + // Featured models from control manifest + for (const [entryId, entry] of Object.entries(controlModels)) { + if (!entry.featured || placed.has(entryId)) { + continue; + } + const model = resolveModel(entryId); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: 'update' }); + } else { + promotedItems.push({ kind: 'available', model }); } - items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, undefined, showActionLink)); + } else if (!model && !entry.exists) { + markPlaced(entryId); + promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: getUnavailableReason(entry) }); } } - } - // --- 3. Other Models (collapsible) --- - otherModels = models - .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) - .sort((a, b) => { - const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; - const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; - if (aCopilot !== bCopilot) { - return aCopilot - bCopilot; + // Render promoted section: sorted alphabetically by name + let hasShownActionLink = false; + if (promotedItems.length > 0) { + promotedItems.sort((a, b) => { + const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; + const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; + return aName.localeCompare(bName); + }); + + for (const item of promotedItems) { + if (item.kind === 'available') { + items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); + } else { + const showActionLink = item.reason === 'upgrade' ? !hasShownActionLink : true; + if (showActionLink && item.reason === 'upgrade') { + hasShownActionLink = true; + } + items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, undefined, showActionLink)); + } } - const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); - return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); - }); - - if (otherModels.length > 0) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator }); } - items.push({ - item: { - id: 'otherModels', - enabled: true, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + + // --- 3. Other Models (collapsible) --- + otherModels = models + .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) + .sort((a, b) => { + const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; + const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; + if (aCopilot !== bCopilot) { + return aCopilot - bCopilot; + } + const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); + return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); + }); + + if (otherModels.length > 0) { + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + } + items.push({ + item: { + id: 'otherModels', + enabled: true, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + label: localize('chat.modelPicker.otherModels', "Other Models"), + run: () => { /* toggle handled by isSectionToggle */ } + }, + kind: ActionListItemKind.Action, label: localize('chat.modelPicker.otherModels', "Other Models"), - run: () => { /* toggle handled by isSectionToggle */ } - }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.otherModels', "Other Models"), - group: { title: '', icon: Codicon.chevronDown }, - hideIcon: false, - section: ModelPickerSection.Other, - isSectionToggle: true, - }); - for (const model of otherModels) { - const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other, true)); - } else { - items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: ModelPickerSection.Other, + isSectionToggle: true, + }); + for (const model of otherModels) { + const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other, true)); + } else { + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); + } } } } @@ -513,21 +532,22 @@ export class ModelPickerWidget extends Disposable { const items = buildModelPickerItems( models, this._selectedModel?.identifier, - this._languageModelsService.getRecentlyUsedModelIds(), + showCuratedModels ? this._languageModelsService.getRecentlyUsedModelIds() : [], controlModelsForTier, this._productService.version, this._updateService.state.type, onSelect, this._productService.defaultChatAgent?.manageSettingsUrl, this._commandService, - this._entitlementService + this._entitlementService, + showCuratedModels ); const listOptions = { showFilter: models.length >= 10, filterPlaceholder: localize('chat.modelPicker.search', "Search models"), focusFilterOnOpen: true, - collapsedByDefault: new Set([ModelPickerSection.Other]), + collapsedByDefault: showCuratedModels ? new Set([ModelPickerSection.Other]) : new Set(), minWidth: 300, }; const previouslyFocusedElement = dom.getActiveElement(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index 7709101f30273..451bd29c4f92d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -29,7 +29,6 @@ export interface IModelPickerDelegate { readonly currentModel: IObservable; setModel(model: ILanguageModelChatMetadataAndIdentifier): void; getModels(): ILanguageModelChatMetadataAndIdentifier[]; - canManageModels(): boolean; /** * Whether to show curated models from the control manifest (featured, unavailable, upgrade prompts, etc.). * Defaults to `true`. @@ -174,7 +173,7 @@ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { const baseActionBarActionProvider = getModelPickerActionBarActionProvider(commandService, chatEntitlementService, productService); const modelPickerActionWidgetOptions: Omit = { actionProvider: modelDelegateToWidgetActionsProvider(delegate, telemetryService, pickerOptions), - actionBarActionProvider: { getActions: () => delegate.canManageModels() ? baseActionBarActionProvider.getActions() : [] }, + actionBarActionProvider: { getActions: () => baseActionBarActionProvider.getActions() }, reporter: { id: 'ChatModelPicker', name: 'ChatModelPicker', includeOptions: true }, };