diff --git a/cli/src/bin/code/legacy_args.rs b/cli/src/bin/code/legacy_args.rs index 5a8e0f01071..0bd92c92fd3 100644 --- a/cli/src/bin/code/legacy_args.rs +++ b/cli/src/bin/code/legacy_args.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use cli::commands::args::{ CliCore, Commands, DesktopCodeOptions, ExtensionArgs, ExtensionSubcommand, - InstallExtensionArgs, ListExtensionArgs, UninstallExtensionArgs, DownloadExtensionArgs, + InstallExtensionArgs, ListExtensionArgs, UninstallExtensionArgs, }; /// Tries to parse the argv using the legacy CLI interface, looking for its @@ -64,7 +64,6 @@ pub fn try_parse_legacy( // Now translate them to subcommands. // --list-extensions -> ext list // --update-extensions -> update - // --download-extension -> ext download // --install-extension=id -> ext install // --uninstall-extension=id -> ext uninstall // --status -> status @@ -80,17 +79,6 @@ pub fn try_parse_legacy( })), ..Default::default() }) - } else if let Some(exts) = args.get("download-extension") { - Some(CliCore { - subcommand: Some(Commands::Extension(ExtensionArgs { - subcommand: ExtensionSubcommand::Download(DownloadExtensionArgs { - id: exts.to_vec(), - location: get_first_arg_value("location"), - }), - desktop_code_options, - })), - ..Default::default() - }) } else if let Some(exts) = args.remove("install-extension") { Some(CliCore { subcommand: Some(Commands::Extension(ExtensionArgs { diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index 3f4dfd9b7a4..21efc1c6667 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -272,8 +272,6 @@ pub enum ExtensionSubcommand { Uninstall(UninstallExtensionArgs), /// Update the installed extensions. Update, - /// Download an extension. - Download(DownloadExtensionArgs), } impl ExtensionSubcommand { @@ -307,16 +305,6 @@ impl ExtensionSubcommand { ExtensionSubcommand::Update => { target.push("--update-extensions".to_string()); } - ExtensionSubcommand::Download(args) => { - for id in args.id.iter() { - target.push(format!("--download-extension={id}")); - } - if let Some(location) = &args.location { - if !location.is_empty() { - target.push(format!("--location={location}")); - } - } - } } } } @@ -359,21 +347,6 @@ pub struct UninstallExtensionArgs { pub id: Vec, } -#[derive(Args, Debug, Clone)] -pub struct DownloadExtensionArgs { - /// Id of the extension to download. The identifier of an - /// extension is '${publisher}.${name}'. Should provide '--location' to specify the location to download the VSIX. - /// To download a specific version provide '@${version}'. - /// For example: 'vscode.csharp@1.2.3'. - #[clap(name = "ext-id")] - pub id: Vec, - - /// Specify the location to download the VSIX. - #[clap(long, value_name = "location")] - pub location: Option, - -} - #[derive(Args, Debug, Clone)] pub struct VersionArgs { #[clap(subcommand)] diff --git a/extensions/typescript-language-features/src/typescriptServiceClient.ts b/extensions/typescript-language-features/src/typescriptServiceClient.ts index b5fdb185101..d8ed0b2b284 100644 --- a/extensions/typescript-language-features/src/typescriptServiceClient.ts +++ b/extensions/typescript-language-features/src/typescriptServiceClient.ts @@ -125,7 +125,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType private _isPromptingAfterCrash = false; private isRestarting: boolean = false; private hasServerFatallyCrashedTooManyTimes = false; - private readonly loadingIndicator = this._register(new ServerInitializingIndicator()); + private readonly loadingIndicator: ServerInitializingIndicator; public readonly telemetryReporter: TelemetryReporter; public readonly bufferSyncSupport: BufferSyncSupport; @@ -158,6 +158,8 @@ export default class TypeScriptServiceClient extends Disposable implements IType ) { super(); + this.loadingIndicator = this._register(new ServerInitializingIndicator(this)); + this.logger = services.logger; this.tracer = new Tracer(this.logger); @@ -1254,6 +1256,12 @@ class ServerInitializingIndicator extends Disposable { private _task?: { project: string; resolve: () => void }; + constructor( + private readonly client: ITypeScriptServiceClient, + ) { + super(); + } + public reset(): void { if (this._task) { this._task.resolve(); @@ -1269,15 +1277,28 @@ class ServerInitializingIndicator extends Disposable { // the incoming project loading task is. this.reset(); - const projectDisplayName = vscode.workspace.asRelativePath(projectName); + const projectDisplayName = this.getProjectDisplayName(projectName); + vscode.window.withProgress({ location: vscode.ProgressLocation.Window, - title: vscode.l10n.t("Initializing project '{0}'", projectDisplayName), + title: vscode.l10n.t("Initializing '{0}'", projectDisplayName), }, () => new Promise(resolve => { this._task = { project: projectName, resolve }; })); } + private getProjectDisplayName(projectName: string): string { + const projectUri = this.client.toResource(projectName); + const relPath = vscode.workspace.asRelativePath(projectUri); + + const maxDisplayLength = 60; + if (relPath.length > maxDisplayLength) { + return '...' + relPath.slice(-maxDisplayLength); + } + + return relPath; + } + public startedLoadingFile(fileName: string, task: Promise): void { if (!this._task) { vscode.window.withProgress({ diff --git a/package.json b/package.json index c187b1c6795..9b56e2f67de 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.96.0", - "distro": "4c96b4c357f6753fff9527bbaf38745be0aee80b", + "distro": "4460d4a3498ebd6eabd7ab5552d3ca2600026f99", "author": { "name": "Microsoft Corporation" }, diff --git a/resources/completions/bash/code b/resources/completions/bash/code index 92b1b4cfcfb..d141c297b96 100644 --- a/resources/completions/bash/code +++ b/resources/completions/bash/code @@ -49,7 +49,7 @@ _@@APPNAME@@() --list-extensions --show-versions --install-extension --uninstall-extension --enable-proposed-api --verbose --log -s --status -p --performance --prof-startup --disable-extensions - --disable-extension --inspect-extensions --update-extensions --download-extension + --disable-extension --inspect-extensions --update-extensions --inspect-brk-extensions --disable-gpu' -- "$cur") ) [[ $COMPREPLY == *= ]] && compopt -o nospace return diff --git a/resources/completions/zsh/_code b/resources/completions/zsh/_code index f49211447ba..eafa37c81c7 100644 --- a/resources/completions/zsh/_code +++ b/resources/completions/zsh/_code @@ -20,9 +20,8 @@ arguments=( '--category[filters installed extension list by category, when using --list-extensions]' '--show-versions[show versions of installed extensions, when using --list-extensions]' '--install-extension[install an extension]:id or path:_files -g "*.vsix(-.)"' - '--uninstall-extension[uninstall an extension]:id' + '--uninstall-extension[uninstall an extension]:id or path:_files -g "*.vsix(-.)"' '--update-extensions[update the installed extensions]' - '--download-extension[download an extension]:id' '--enable-proposed-api[enables proposed API features for extensions]::extension id: ' '--verbose[print verbose output (implies --wait)]' '--log[log level to use]:level [info]:(critical error warn info debug trace off)' diff --git a/src/vs/base/browser/cssValue.ts b/src/vs/base/browser/cssValue.ts index c758b629a71..975edb7cfa4 100644 --- a/src/vs/base/browser/cssValue.ts +++ b/src/vs/base/browser/cssValue.ts @@ -2,9 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Color } from '../common/color.js'; import { FileAccess } from '../common/network.js'; import { URI } from '../common/uri.js'; +export type CssFragment = string & { readonly __cssFragment: unique symbol }; + +function asFragment(raw: string): CssFragment { + return raw as CssFragment; +} + export function asCssValueWithDefault(cssPropertyValue: string | undefined, dflt: string): string { if (cssPropertyValue !== undefined) { const variableMatch = cssPropertyValue.match(/^\s*var\((.+)\)$/); @@ -20,16 +27,59 @@ export function asCssValueWithDefault(cssPropertyValue: string | undefined, dflt return dflt; } -export function asCSSPropertyValue(value: string) { - return `'${value.replace(/'/g, '%27')}'`; +export function value(value: string): CssFragment { + const out = value.replaceAll(/[^_\-a-z0-9]/gi, ''); + if (out !== value) { + console.warn(`CSS value ${value} modified to ${out} to be safe for CSS`); + } + return asFragment(out); +} + +export function stringValue(value: string): CssFragment { + return asFragment(`'${value.replaceAll(/'/g, '\\000027')}'`); } /** * returns url('...') */ -export function asCSSUrl(uri: URI | null | undefined): string { +export function asCSSUrl(uri: URI | null | undefined): CssFragment { if (!uri) { - return `url('')`; + return asFragment(`url('')`); + } + return inline`url(${stringValue(FileAccess.uriToBrowserUri(uri).toString(true))})`; +} + +export function className(value: string): CssFragment { + const out = CSS.escape(value); + if (out !== value) { + console.warn(`CSS class name ${value} modified to ${out} to be safe for CSS`); + } + return asFragment(out); +} + +type InlineCssTemplateValue = CssFragment | Color; + +/** + * Template string tag that that constructs a CSS fragment. + * + * All expressions in the template must be css safe values. + */ +export function inline(strings: TemplateStringsArray, ...values: InlineCssTemplateValue[]): CssFragment { + return asFragment(strings.reduce((result, str, i) => { + const value = values[i] || ''; + return result + str + value; + }, '')); +} + + +export class Builder { + private readonly _parts: CssFragment[] = []; + + push(...parts: CssFragment[]): void { + this._parts.push(...parts); + } + + join(joiner = '\n'): CssFragment { + return asFragment(this._parts.join(joiner)); } - return `url('${FileAccess.uriToBrowserUri(uri).toString(true).replace(/'/g, '%27')}')`; } diff --git a/src/vs/base/browser/ui/countBadge/countBadge.ts b/src/vs/base/browser/ui/countBadge/countBadge.ts index 717ccfab41f..d9f61bae90a 100644 --- a/src/vs/base/browser/ui/countBadge/countBadge.ts +++ b/src/vs/base/browser/ui/countBadge/countBadge.ts @@ -6,7 +6,7 @@ import { $, append } from '../../dom.js'; import { format } from '../../../common/strings.js'; import './countBadge.css'; -import { Disposable, IDisposable, toDisposable } from '../../../common/lifecycle.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../common/lifecycle.js'; import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js'; export interface ICountBadgeOptions { @@ -33,7 +33,7 @@ export class CountBadge extends Disposable { private count: number = 0; private countFormat: string; private titleFormat: string; - private hover: IDisposable | undefined; + private readonly hover = this._register(new MutableDisposable()); constructor(container: HTMLElement, private readonly options: ICountBadgeOptions, private readonly styles: ICountBadgeStyles) { @@ -43,6 +43,7 @@ export class CountBadge extends Disposable { this.countFormat = this.options.countFormat || '{0}'; this.titleFormat = this.options.titleFormat || ''; this.setCount(this.options.count || 0); + this.updateHover(); } setCount(count: number) { @@ -57,14 +58,15 @@ export class CountBadge extends Disposable { setTitleFormat(titleFormat: string) { this.titleFormat = titleFormat; + this.updateHover(); this.render(); } private updateHover(): void { - this.hover?.dispose(); - this.hover = undefined; - if (this.titleFormat !== '') { - this.hover = getBaseLayerHoverDelegate().setupDelayedHoverAtMouse(this.element, { content: format(this.titleFormat, this.count), appearance: { compact: true } }); + if (this.titleFormat !== '' && !this.hover.value) { + this.hover.value = getBaseLayerHoverDelegate().setupDelayedHoverAtMouse(this.element, () => ({ content: format(this.titleFormat, this.count), appearance: { compact: true } })); + } else if (this.titleFormat === '' && this.hover.value) { + this.hover.value = undefined; } } @@ -77,7 +79,5 @@ export class CountBadge extends Disposable { if (this.styles.badgeBorder) { this.element.style.border = `1px solid ${this.styles.badgeBorder}`; } - - this.updateHover(); } } diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 39033f27227..e91bea07d6a 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -33,7 +33,6 @@ function shouldSpawnCliProcess(argv: NativeParsedArgs): boolean { return !!argv['install-source'] || !!argv['list-extensions'] || !!argv['install-extension'] - || !!argv['download-extension'] || !!argv['uninstall-extension'] || !!argv['update-extensions'] || !!argv['locate-extension'] diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index bbca94ab07c..d3b20ecea47 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -282,14 +282,6 @@ class CliMain extends Disposable { return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).listExtensions(!!this.argv['show-versions'], this.argv['category'], profileLocation); } - // Download Extensions - else if (this.argv['download-extension']) { - if (!this.argv['location']) { - throw new Error('The location argument is required to download an extension.'); - } - return instantiationService.createInstance(ExtensionManagementCLI, new ConsoleLogger(LogLevel.Info, false)).downloadExtensions(this.argv['download-extension'], URI.parse(this.argv['location'])); - } - // Install Extension else if (this.argv['install-extension'] || this.argv['install-builtin-extension']) { const installOptions: InstallOptions = { isMachineScoped: !!this.argv['do-not-sync'], installPreReleaseVersion: !!this.argv['pre-release'], profileLocation }; diff --git a/src/vs/editor/browser/controller/mouseHandler.ts b/src/vs/editor/browser/controller/mouseHandler.ts index cd9b92c4ed0..b1e326d835a 100644 --- a/src/vs/editor/browser/controller/mouseHandler.ts +++ b/src/vs/editor/browser/controller/mouseHandler.ts @@ -21,11 +21,13 @@ import { ViewEventHandler } from '../../common/viewEventHandler.js'; import { EditorOption } from '../../common/config/editorOptions.js'; import { NavigationCommandRevealType } from '../coreCommands.js'; import { MouseWheelClassifier } from '../../../base/browser/ui/scrollbar/scrollableElement.js'; +import type { ViewLinesGpu } from '../viewParts/viewLinesGpu/viewLinesGpu.js'; export interface IPointerHandlerHelper { viewDomNode: HTMLElement; linesContentDomNode: HTMLElement; viewLinesDomNode: HTMLElement; + viewLinesGpu: ViewLinesGpu | undefined; focusTextArea(): void; dispatchTextAreaEvent(event: CustomEvent): void; diff --git a/src/vs/editor/browser/controller/mouseTarget.ts b/src/vs/editor/browser/controller/mouseTarget.ts index 737daf6d294..28b39952393 100644 --- a/src/vs/editor/browser/controller/mouseTarget.ts +++ b/src/vs/editor/browser/controller/mouseTarget.ts @@ -22,6 +22,7 @@ import { PositionAffinity } from '../../common/model.js'; import { InjectedText } from '../../common/modelLineProjectionData.js'; import { Mutable } from '../../../base/common/types.js'; import { Lazy } from '../../../base/common/lazy.js'; +import type { ViewLinesGpu } from '../viewParts/viewLinesGpu/viewLinesGpu.js'; const enum HitTestResultType { Unknown, @@ -238,6 +239,7 @@ export class HitTestContext { public readonly viewModel: IViewModel; public readonly layoutInfo: EditorLayoutInfo; public readonly viewDomNode: HTMLElement; + public readonly viewLinesGpu: ViewLinesGpu | undefined; public readonly lineHeight: number; public readonly stickyTabStops: boolean; public readonly typicalHalfwidthCharacterWidth: number; @@ -251,6 +253,7 @@ export class HitTestContext { const options = context.configuration.options; this.layoutInfo = options.get(EditorOption.layoutInfo); this.viewDomNode = viewHelper.viewDomNode; + this.viewLinesGpu = viewHelper.viewLinesGpu; this.lineHeight = options.get(EditorOption.lineHeight); this.stickyTabStops = options.get(EditorOption.stickyTabStops); this.typicalHalfwidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth; @@ -754,6 +757,32 @@ export class MouseTargetFactory { const pos = new Position(lineNumber, ctx.viewModel.getLineMaxColumn(lineNumber)); return request.fulfillContentEmpty(pos, detail); } + } else { + if (ctx.viewLinesGpu) { + const lineNumber = ctx.getLineNumberAtVerticalOffset(request.mouseVerticalOffset); + if (ctx.viewModel.getLineLength(lineNumber) === 0) { + const lineWidth = ctx.getLineWidth(lineNumber); + const detail = createEmptyContentDataInLines(request.mouseContentHorizontalOffset - lineWidth); + return request.fulfillContentEmpty(new Position(lineNumber, 1), detail); + } + + const lineWidth = ctx.getLineWidth(lineNumber); + if (request.mouseContentHorizontalOffset >= lineWidth) { + // TODO: This is wrong for RTL + const detail = createEmptyContentDataInLines(request.mouseContentHorizontalOffset - lineWidth); + const pos = new Position(lineNumber, ctx.viewModel.getLineMaxColumn(lineNumber)); + return request.fulfillContentEmpty(pos, detail); + } + + const position = ctx.viewLinesGpu.getPositionAtCoordinate(lineNumber, request.mouseContentHorizontalOffset); + if (position) { + const detail: IMouseTargetContentTextData = { + injectedText: null, + mightBeForeignElement: false + }; + return request.fulfillContentText(position, EditorRange.fromPositions(position, position), detail); + } + } } // Do the hit test (if not already done) diff --git a/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts b/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts index 0caae56750d..0a681d2fe84 100644 --- a/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts +++ b/src/vs/editor/browser/gpu/fullFileRenderStrategy.ts @@ -5,11 +5,11 @@ import { getActiveWindow } from '../../../base/browser/dom.js'; import { BugIndicatingError } from '../../../base/common/errors.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; import { EditorOption } from '../../common/config/editorOptions.js'; import { CursorColumns } from '../../common/core/cursorColumns.js'; import type { IViewLineTokens } from '../../common/tokens/lineTokens.js'; -import type { ViewLinesDeletedEvent } from '../../common/viewEvents.js'; +import { ViewEventHandler } from '../../common/viewEventHandler.js'; +import type { ViewLinesDeletedEvent, ViewScrollChangedEvent } from '../../common/viewEvents.js'; import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; import type { ViewLineRenderingData } from '../../common/viewModel.js'; import type { ViewContext } from '../../common/viewModel/viewContext.js'; @@ -38,7 +38,7 @@ const enum CellBufferInfo { TextureIndex = 5, } -export class FullFileRenderStrategy extends Disposable implements IGpuRenderStrategy { +export class FullFileRenderStrategy extends ViewEventHandler implements IGpuRenderStrategy { readonly wgsl: string = fullFileRenderStrategyWgsl; @@ -57,8 +57,9 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra private _visibleObjectCount: number = 0; private _finalRenderedLine: number = 0; - private _scrollOffsetBindBuffer!: GPUBuffer; - private _scrollOffsetValueBuffers!: [Float32Array, Float32Array]; + private _scrollOffsetBindBuffer: GPUBuffer; + private _scrollOffsetValueBuffer: Float32Array; + private _scrollInitialized: boolean = false; private readonly _queuedBufferUpdates: [ViewLinesDeletedEvent[], ViewLinesDeletedEvent[]] = [[], []]; @@ -76,6 +77,8 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra ) { super(); + this._context.addEventHandler(this); + // TODO: Detect when lines have been tokenized and clear _upToDateLines const fontFamily = this._context.configuration.options.get(EditorOption.fontFamily); const fontSize = this._context.configuration.options.get(EditorOption.fontSize); @@ -99,12 +102,26 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra size: scrollOffsetBufferSize * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, })).object; - this._scrollOffsetValueBuffers = [ - new Float32Array(scrollOffsetBufferSize), - new Float32Array(scrollOffsetBufferSize), - ]; + this._scrollOffsetValueBuffer = new Float32Array(scrollOffsetBufferSize); } + // #region Event handlers + + public override onLinesDeleted(e: ViewLinesDeletedEvent): boolean { + this._queueBufferUpdate(e); + return true; + } + + public override onScrollChanged(e?: ViewScrollChangedEvent): boolean { + const dpr = getActiveWindow().devicePixelRatio; + this._scrollOffsetValueBuffer[0] = (e?.scrollLeft ?? this._context.viewLayout.getCurrentScrollLeft()) * dpr; + this._scrollOffsetValueBuffer[1] = (e?.scrollTop ?? this._context.viewLayout.getCurrentScrollTop()) * dpr; + this._device.queue.writeBuffer(this._scrollOffsetBindBuffer, 0, this._scrollOffsetValueBuffer); + return true; + } + + // #endregion + reset() { for (const bufferIndex of [0, 1]) { // Zero out buffer and upload to GPU to prevent stale rows from rendering @@ -122,12 +139,8 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra let chars = ''; let y = 0; let x = 0; - let screenAbsoluteX = 0; - let screenAbsoluteY = 0; - let zeroToOneX = 0; - let zeroToOneY = 0; - let wgslX = 0; - let wgslY = 0; + let absoluteOffsetX = 0; + let absoluteOffsetY = 0; let xOffset = 0; let glyph: Readonly; let cellIndex = 0; @@ -145,11 +158,10 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra const dpr = getActiveWindow().devicePixelRatio; - // Update scroll offset - const scrollOffsetBuffer = this._scrollOffsetValueBuffers[this._activeDoubleBufferIndex]; - scrollOffsetBuffer[0] = this._context.viewLayout.getCurrentScrollLeft() * dpr; - scrollOffsetBuffer[1] = this._context.viewLayout.getCurrentScrollTop() * dpr; - this._device.queue.writeBuffer(this._scrollOffsetBindBuffer, 0, scrollOffsetBuffer); + if (!this._scrollInitialized) { + this.onScrollChanged(); + this._scrollInitialized = true; + } // Update cell data const cellBuffer = new Float32Array(this._cellValueBuffers[this._activeDoubleBufferIndex]); @@ -200,29 +212,6 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra content = lineData.content; xOffset = 0; - // See ViewLine#renderLine - // const renderLineInput = new RenderLineInput( - // options.useMonospaceOptimizations, - // options.canUseHalfwidthRightwardsArrow, - // lineData.content, - // lineData.continuesWithWrappedLine, - // lineData.isBasicASCII, - // lineData.containsRTL, - // lineData.minColumn - 1, - // lineData.tokens, - // actualInlineDecorations, - // lineData.tabSize, - // lineData.startVisibleColumn, - // options.spaceWidth, - // options.middotWidth, - // options.wsmiddotWidth, - // options.stopRenderingLineAfter, - // options.renderWhitespace, - // options.renderControlCharacters, - // options.fontLigatures !== EditorFontLigatures.OFF, - // selectionsOnLine - // ); - tokens = lineData.tokens; tokenStartIndex = lineData.minColumn - 1; tokenEndIndex = 0; @@ -255,8 +244,8 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra glyph = this._viewGpuContext.atlas.getGlyph(this._glyphRasterizer, chars, tokenMetadata); // TODO: Support non-standard character widths - screenAbsoluteX = Math.round((x + xOffset) * viewLineOptions.spaceWidth * dpr); - screenAbsoluteY = ( + absoluteOffsetX = Math.round((x + xOffset) * viewLineOptions.spaceWidth * dpr); + absoluteOffsetY = ( Math.ceil(( // Top of line including line height viewportData.relativeVerticalOffset[y - viewportData.startLineNumber] + @@ -264,14 +253,10 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra Math.floor((viewportData.lineHeight - this._context.configuration.options.get(EditorOption.fontSize)) / 2) ) * dpr) ); - zeroToOneX = screenAbsoluteX / this._viewGpuContext.canvas.domNode.width; - zeroToOneY = screenAbsoluteY / this._viewGpuContext.canvas.domNode.height; - wgslX = zeroToOneX * 2 - 1; - wgslY = zeroToOneY * 2 - 1; cellIndex = ((y - 1) * this._viewGpuContext.maxGpuCols + x) * Constants.IndicesPerCell; - cellBuffer[cellIndex + CellBufferInfo.Offset_X] = wgslX; - cellBuffer[cellIndex + CellBufferInfo.Offset_Y] = -wgslY; + cellBuffer[cellIndex + CellBufferInfo.Offset_X] = absoluteOffsetX; + cellBuffer[cellIndex + CellBufferInfo.Offset_Y] = absoluteOffsetY; cellBuffer[cellIndex + CellBufferInfo.GlyphIndex] = glyph.glyphIndex; cellBuffer[cellIndex + CellBufferInfo.TextureIndex] = glyph.pageIndex; } @@ -325,8 +310,4 @@ export class FullFileRenderStrategy extends Disposable implements IGpuRenderStra this._queuedBufferUpdates[0].push(e); this._queuedBufferUpdates[1].push(e); } - - onLinesDeleted(e: ViewLinesDeletedEvent): void { - this._queueBufferUpdate(e); - } } diff --git a/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts b/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts index c5072ffb616..531986de396 100644 --- a/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts +++ b/src/vs/editor/browser/gpu/fullFileRenderStrategy.wgsl.ts @@ -67,7 +67,12 @@ struct VSOutput { var vsOut: VSOutput; // Multiple vert.position by 2,-2 to get it into clipspace which ranged from -1 to 1 vsOut.position = vec4f( - (((vert.position * vec2f(2, -2)) / layoutInfo.canvasDims)) * glyph.size + cell.position + ((glyph.origin * vec2f(2, -2)) / layoutInfo.canvasDims) + (((layoutInfo.viewportOffset - scrollOffset.offset * vec2(1, -1)) * 2) / layoutInfo.canvasDims), + // Make everything relative to top left instead of center + vec2f(-1, 1) + + ((vert.position * vec2f(2, -2)) / layoutInfo.canvasDims) * glyph.size + + ((cell.position * vec2f(2, -2)) / layoutInfo.canvasDims) + + ((glyph.origin * vec2f(2, -2)) / layoutInfo.canvasDims) + + (((layoutInfo.viewportOffset - scrollOffset.offset * vec2(1, -1)) * 2) / layoutInfo.canvasDims), 0.0, 1.0 ); diff --git a/src/vs/editor/browser/gpu/rectangleRenderer.ts b/src/vs/editor/browser/gpu/rectangleRenderer.ts index 0859281f48b..54ac68f9b98 100644 --- a/src/vs/editor/browser/gpu/rectangleRenderer.ts +++ b/src/vs/editor/browser/gpu/rectangleRenderer.ts @@ -8,7 +8,7 @@ import { Event } from '../../../base/common/event.js'; import { IReference, MutableDisposable } from '../../../base/common/lifecycle.js'; import { EditorOption } from '../../common/config/editorOptions.js'; import { ViewEventHandler } from '../../common/viewEventHandler.js'; -import type { ViewScrollChangedEvent } from '../../common/viewEvents.js'; +import type { ViewCursorStateChangedEvent, ViewScrollChangedEvent } from '../../common/viewEvents.js'; import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js'; import type { ViewContext } from '../../common/viewModel/viewContext.js'; import { GPULifecycle } from './gpuDisposable.js'; @@ -42,7 +42,6 @@ export class RectangleRenderer extends ViewEventHandler { private _scrollOffsetValueBuffer!: Float32Array; private _initialized: boolean = false; - private _scrollChanged: boolean = true; private readonly _shapeCollection: IObjectCollectionBuffer = this._register(createObjectCollectionBuffer([ { name: 'x' }, @@ -242,29 +241,33 @@ export class RectangleRenderer extends ViewEventHandler { return this._shapeCollection.createEntry({ x, y, width, height, red, green, blue, alpha }); } - // --- begin event handlers + // #region Event handlers public override onScrollChanged(e: ViewScrollChangedEvent): boolean { - this._scrollChanged = true; - return super.onScrollChanged(e); + return true; } - // --- end event handlers - - private _update() { - const shapes = this._shapeCollection; - if (shapes.dirtyTracker.isDirty) { - this._device.queue.writeBuffer(this._shapeBindBuffer.value!.object, 0, shapes.buffer, shapes.dirtyTracker.dataOffset, shapes.dirtyTracker.dirtySize! * shapes.view.BYTES_PER_ELEMENT); - shapes.dirtyTracker.clear(); - } - - // Update scroll offset - if (this._scrollChanged) { + public override onCursorStateChanged(e: ViewCursorStateChangedEvent): boolean { + if (this._device) { const dpr = getActiveWindow().devicePixelRatio; this._scrollOffsetValueBuffer[0] = this._context.viewLayout.getCurrentScrollLeft() * dpr; this._scrollOffsetValueBuffer[1] = this._context.viewLayout.getCurrentScrollTop() * dpr; this._device.queue.writeBuffer(this._scrollOffsetBindBuffer, 0, this._scrollOffsetValueBuffer); } + return true; + } + + // #endregion + + private _update() { + if (!this._device) { + return; + } + const shapes = this._shapeCollection; + if (shapes.dirtyTracker.isDirty) { + this._device.queue.writeBuffer(this._shapeBindBuffer.value!.object, 0, shapes.buffer, shapes.dirtyTracker.dataOffset, shapes.dirtyTracker.dirtySize! * shapes.view.BYTES_PER_ELEMENT); + shapes.dirtyTracker.clear(); + } } draw(viewportData: ViewportData) { diff --git a/src/vs/editor/browser/view.ts b/src/vs/editor/browser/view.ts index 06ba7f69539..cef27e15410 100644 --- a/src/vs/editor/browser/view.ts +++ b/src/vs/editor/browser/view.ts @@ -329,6 +329,7 @@ export class View extends ViewEventHandler { viewDomNode: this.domNode.domNode, linesContentDomNode: this._linesContent.domNode, viewLinesDomNode: this._viewLines.getDomNode().domNode, + viewLinesGpu: this._viewLinesGpu, focusTextArea: () => { this.focus(); @@ -359,11 +360,18 @@ export class View extends ViewEventHandler { visibleRangeForPosition: (lineNumber: number, column: number) => { this._flushAccumulatedAndRenderNow(); - return this._viewLines.visibleRangeForPosition(new Position(lineNumber, column)); + const position = new Position(lineNumber, column); + return this._viewLines.visibleRangeForPosition(position) ?? this._viewLinesGpu?.visibleRangeForPosition(position) ?? null; }, getLineWidth: (lineNumber: number) => { this._flushAccumulatedAndRenderNow(); + if (this._viewLinesGpu) { + const result = this._viewLinesGpu.getLineWidth(lineNumber); + if (result !== undefined) { + return result; + } + } return this._viewLines.getLineWidth(lineNumber); } }; diff --git a/src/vs/editor/browser/view/renderingContext.ts b/src/vs/editor/browser/view/renderingContext.ts index 178f546082e..f0d5b5357c1 100644 --- a/src/vs/editor/browser/view/renderingContext.ts +++ b/src/vs/editor/browser/view/renderingContext.ts @@ -80,7 +80,22 @@ export class RenderingContext extends RestrictedRenderingContext { } public linesVisibleRangesForRange(range: Range, includeNewLines: boolean): LineVisibleRanges[] | null { - return this._viewLines.linesVisibleRangesForRange(range, includeNewLines) ?? this._viewLinesGpu?.linesVisibleRangesForRange(range, includeNewLines) ?? null; + const domRanges = this._viewLines.linesVisibleRangesForRange(range, includeNewLines); + if (!this._viewLinesGpu) { + return domRanges ?? null; + } + const gpuRanges = this._viewLinesGpu.linesVisibleRangesForRange(range, includeNewLines); + if (!domRanges && !gpuRanges) { + return null; + } + const ranges = []; + if (domRanges) { + ranges.push(...domRanges); + } + if (gpuRanges) { + ranges.push(...gpuRanges); + } + return ranges; } public visibleRangeForPosition(position: Position): HorizontalPosition | null { diff --git a/src/vs/editor/browser/view/viewPart.ts b/src/vs/editor/browser/view/viewPart.ts index 78467eb2e23..a23bcb11b59 100644 --- a/src/vs/editor/browser/view/viewPart.ts +++ b/src/vs/editor/browser/view/viewPart.ts @@ -37,7 +37,8 @@ export const enum PartFingerprint { ScrollableElement, TextArea, ViewLines, - Minimap + Minimap, + ViewLinesGpu } export class PartFingerprints { diff --git a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts index ac22b39a6d6..e86c2f12ef0 100644 --- a/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts +++ b/src/vs/editor/browser/viewParts/viewLinesGpu/viewLinesGpu.ts @@ -5,25 +5,24 @@ import { getActiveWindow } from '../../../../base/browser/dom.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorun, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; -import type { Position } from '../../../common/core/position.js'; -import type { Range } from '../../../common/core/range.js'; -import type { ViewLinesChangedEvent, ViewLinesDeletedEvent, ViewScrollChangedEvent } from '../../../common/viewEvents.js'; +import { Position } from '../../../common/core/position.js'; +import { Range } from '../../../common/core/range.js'; import type { ViewportData } from '../../../common/viewLayout/viewLinesViewportData.js'; import type { ViewContext } from '../../../common/viewModel/viewContext.js'; import { TextureAtlasPage } from '../../gpu/atlas/textureAtlasPage.js'; import { FullFileRenderStrategy } from '../../gpu/fullFileRenderStrategy.js'; import { BindingId, type IGpuRenderStrategy } from '../../gpu/gpu.js'; import { GPULifecycle } from '../../gpu/gpuDisposable.js'; -import { observeDevicePixelDimensions, quadVertices } from '../../gpu/gpuUtils.js'; +import { quadVertices } from '../../gpu/gpuUtils.js'; import { ViewGpuContext } from '../../gpu/viewGpuContext.js'; -import { FloatHorizontalRange, HorizontalPosition, IViewLines, LineVisibleRanges, RenderingContext, RestrictedRenderingContext, VisibleRanges } from '../../view/renderingContext.js'; +import { FloatHorizontalRange, HorizontalPosition, HorizontalRange, IViewLines, LineVisibleRanges, RenderingContext, RestrictedRenderingContext, VisibleRanges } from '../../view/renderingContext.js'; import { ViewPart } from '../../view/viewPart.js'; import { ViewLineOptions } from '../viewLines/viewLineOptions.js'; - +import type * as viewEvents from '../../../common/viewEvents.js'; const enum GlyphStorageBufferInfo { FloatsPerEntry = 2 + 2 + 2, @@ -60,6 +59,8 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { private _renderStrategy!: IGpuRenderStrategy; + private _contentLeftObs = observableValue('contentLeft', 0); + constructor( context: ViewContext, private readonly _viewGpuContext: ViewGpuContext, @@ -154,8 +155,11 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { size: Info.BytesPerEntry, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }, () => updateBufferValues())).object; - this._register(observeDevicePixelDimensions(this.canvas, getActiveWindow(), (w, h) => { - this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues(w, h)); + this._register(runOnChange(this._viewGpuContext.canvasDevicePixelDimensions, ({ width, height }) => { + this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues(width, height)); + })); + this._register(runOnChange(this._contentLeftObs, () => { + this._device.queue.writeBuffer(layoutInfoUniformBuffer, 0, updateBufferValues()); })); } @@ -368,20 +372,36 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { throw new BugIndicatingError('Should not be called'); } - override onLinesChanged(e: ViewLinesChangedEvent): boolean { + // #region Event handlers + + // Since ViewLinesGpu currently coordinates rendering to the canvas, it must listen to all + // changed events that any GPU part listens to. This is because any drawing to the canvas will + // clear it for that frame, so all parts must be rendered every time. + // + // Additionally, since this is intrinsically linked to ViewLines, it must also listen to events + // from that side. Luckily rendering is cheap, it's only when uploaded data changes does it + // start to cost. + + override onCursorStateChanged(e: viewEvents.ViewCursorStateChangedEvent): boolean { return true; } + override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean { return true; } + override onFlushed(e: viewEvents.ViewFlushedEvent): boolean { return true; } + override onLinesChanged(e: viewEvents.ViewLinesChangedEvent): boolean { return true; } + override onLinesInserted(e: viewEvents.ViewLinesInsertedEvent): boolean { return true; } + override onRevealRangeRequest(e: viewEvents.ViewRevealRangeRequestEvent): boolean { return true; } + override onScrollChanged(e: viewEvents.ViewScrollChangedEvent): boolean { return true; } + override onThemeChanged(e: viewEvents.ViewThemeChangedEvent): boolean { return true; } + override onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean { return true; } + + override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { + this._contentLeftObs.set(this._context.configuration.options.get(EditorOption.layoutInfo).contentLeft, undefined); return true; } - - override onLinesDeleted(e: ViewLinesDeletedEvent): boolean { + override onLinesDeleted(e: viewEvents.ViewLinesDeletedEvent): boolean { this._renderStrategy.onLinesDeleted(e); return true; } - override onScrollChanged(e: ViewScrollChangedEvent): boolean { - return true; - } - - // subscribe to more events + // #endregion public renderText(viewportData: ViewportData): void { if (this._initialized) { @@ -411,7 +431,6 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { pass.setBindGroup(0, this._bindGroup); if (this._renderStrategy?.draw) { - // TODO: Don't draw lines if ViewLinesGpu.canRender is false this._renderStrategy.draw(pass, viewportData); } else { pass.draw(quadVertices.length / 2, visibleObjectCount); @@ -427,8 +446,65 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { this._lastViewLineOptions = options; } - linesVisibleRangesForRange(range: Range, includeNewLines: boolean): LineVisibleRanges[] | null { - return null; + linesVisibleRangesForRange(_range: Range, includeNewLines: boolean): LineVisibleRanges[] | null { + if (!this._lastViewportData) { + return null; + } + const originalEndLineNumber = _range.endLineNumber; + const range = Range.intersectRanges(_range, this._lastViewportData.visibleRange); + if (!range) { + return null; + } + + const rendStartLineNumber = this._lastViewportData.startLineNumber; + const rendEndLineNumber = this._lastViewportData.endLineNumber; + + const viewportData = this._lastViewportData; + const viewLineOptions = this._lastViewLineOptions; + + if (!viewportData || !viewLineOptions) { + return null; + } + + const visibleRanges: LineVisibleRanges[] = []; + + let nextLineModelLineNumber: number = 0; + if (includeNewLines) { + nextLineModelLineNumber = this._context.viewModel.coordinatesConverter.convertViewPositionToModelPosition(new Position(range.startLineNumber, 1)).lineNumber; + } + + for (let lineNumber = range.startLineNumber; lineNumber <= range.endLineNumber; lineNumber++) { + + if (lineNumber < rendStartLineNumber || lineNumber > rendEndLineNumber) { + continue; + } + const startColumn = lineNumber === range.startLineNumber ? range.startColumn : 1; + const continuesInNextLine = lineNumber !== range.endLineNumber; + const endColumn = continuesInNextLine ? this._context.viewModel.getLineMaxColumn(lineNumber) : range.endColumn; + + const visibleRangesForLine = this._visibleRangesForLineRange(lineNumber, startColumn, endColumn); + + if (!visibleRangesForLine) { + continue; + } + + if (includeNewLines && lineNumber < originalEndLineNumber) { + const currentLineModelLineNumber = nextLineModelLineNumber; + nextLineModelLineNumber = this._context.viewModel.coordinatesConverter.convertViewPositionToModelPosition(new Position(lineNumber + 1, 1)).lineNumber; + + if (currentLineModelLineNumber !== nextLineModelLineNumber) { + visibleRangesForLine.ranges[visibleRangesForLine.ranges.length - 1].width += viewLineOptions.spaceWidth; + } + } + + visibleRanges.push(new LineVisibleRanges(visibleRangesForLine.outsideRenderedLine, lineNumber, HorizontalRange.from(visibleRangesForLine.ranges), continuesInNextLine)); + } + + if (visibleRanges.length === 0) { + return null; + } + + return visibleRanges; } private _visibleRangesForLineRange(lineNumber: number, startColumn: number, endColumn: number): VisibleRanges | null { @@ -448,20 +524,19 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { // Resolve tab widths for this line const lineData = viewportData.getViewLineRenderingData(lineNumber); const content = lineData.content; - let startColumnResolvedTabWidth = 0; - let endColumnResolvedTabWidth = 0; + let resolvedStartColumnLeft = 0; for (let x = 0; x < startColumn - 1; x++) { - startColumnResolvedTabWidth += content[x] === '\t' ? lineData.tabSize : 1; + resolvedStartColumnLeft += content[x] === '\t' ? lineData.tabSize : 1; } - endColumnResolvedTabWidth = startColumnResolvedTabWidth; + let resolvedRangeWidth = 0; for (let x = startColumn - 1; x < endColumn - 1; x++) { - endColumnResolvedTabWidth += content[x] === '\t' ? lineData.tabSize : 1; + resolvedRangeWidth += content[x] === '\t' ? lineData.tabSize : 1; } // Visible horizontal range in _scaled_ pixels const result = new VisibleRanges(false, [new FloatHorizontalRange( - startColumnResolvedTabWidth * viewLineOptions.spaceWidth, - endColumnResolvedTabWidth * viewLineOptions.spaceWidth) + resolvedStartColumnLeft * viewLineOptions.spaceWidth, + resolvedRangeWidth * viewLineOptions.spaceWidth) ]); return result; @@ -474,4 +549,43 @@ export class ViewLinesGpu extends ViewPart implements IViewLines { } return new HorizontalPosition(visibleRanges.outsideRenderedLine, visibleRanges.ranges[0].left); } + + getLineWidth(lineNumber: number): number | undefined { + if (!this._lastViewportData || !this._lastViewLineOptions) { + return undefined; + } + if (!ViewGpuContext.canRender(this._lastViewLineOptions, this._lastViewportData, lineNumber)) { + return undefined; + } + + const lineData = this._lastViewportData.getViewLineRenderingData(lineNumber); + const lineRange = this._visibleRangesForLineRange(lineNumber, 1, lineData.maxColumn); + const lastRange = lineRange?.ranges.at(-1); + if (lastRange) { + return lastRange.width; + } + + return undefined; + } + + getPositionAtCoordinate(lineNumber: number, mouseContentHorizontalOffset: number): Position | undefined { + if (!this._lastViewportData || !this._lastViewLineOptions) { + return undefined; + } + if (!ViewGpuContext.canRender(this._lastViewLineOptions, this._lastViewportData, lineNumber)) { + return undefined; + } + const lineData = this._lastViewportData.getViewLineRenderingData(lineNumber); + const content = lineData.content; + let visualColumn = Math.ceil(mouseContentHorizontalOffset / this._lastViewLineOptions.spaceWidth); + let contentColumn = 0; + while (visualColumn > 0) { + if (visualColumn - (content[contentColumn] === '\t' ? lineData.tabSize : 1) < 0) { + break; + } + visualColumn -= content[contentColumn] === '\t' ? lineData.tabSize : 1; + contentColumn++; + } + return new Position(lineNumber, contentColumn); + } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index 9fbeb4fbd12..329969f7568 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -208,26 +208,32 @@ export class InlineCompletionsModel extends Disposable { const c = this._source.inlineCompletions.read(reader); if (!c) { return undefined; } const cursorPosition = this._primaryPosition.read(reader); - let inlineEditCompletion: InlineCompletionWithUpdatedRange | undefined = undefined; - const filteredCompletions: InlineCompletionWithUpdatedRange[] = []; + let inlineEdit: InlineCompletionWithUpdatedRange | undefined = undefined; + const visibleCompletions: InlineCompletionWithUpdatedRange[] = []; for (const completion of c.inlineCompletions) { if (!completion.inlineCompletion.sourceInlineCompletion.isInlineEdit) { if (completion.isVisible(this.textModel, cursorPosition, reader)) { - filteredCompletions.push(completion); + visibleCompletions.push(completion); } - } else if (filteredCompletions.length === 0 && completion.inlineCompletion.sourceInlineCompletion.isInlineEdit) { - inlineEditCompletion = completion; + } else { + inlineEdit = completion; } } + + if (visibleCompletions.length !== 0) { + // Don't show the inline edit if there is a visible completion + inlineEdit = undefined; + } + return { - items: filteredCompletions, - inlineEditCompletion, + inlineCompletions: visibleCompletions, + inlineEdit, }; }); private readonly _filteredInlineCompletionItems = derivedOpts({ owner: this, equalsFn: itemsEquals() }, reader => { const c = this._inlineCompletionItems.read(reader); - return c?.items ?? []; + return c?.inlineCompletions ?? []; }); public readonly selectedInlineCompletionIndex = derived(this, (reader) => { @@ -295,8 +301,8 @@ export class InlineCompletionsModel extends Disposable { const model = this.textModel; const item = this._inlineCompletionItems.read(reader); - if (item?.inlineEditCompletion) { - let edit = item.inlineEditCompletion.toSingleTextEdit(reader); + if (item?.inlineEdit) { + let edit = item.inlineEdit.toSingleTextEdit(reader); edit = singleTextRemoveCommonPrefix(edit, model); const cursorPos = this._primaryPosition.read(reader); @@ -304,13 +310,13 @@ export class InlineCompletionsModel extends Disposable { const cursorDist = LineRange.fromRange(edit.range).distanceToLine(this._primaryPosition.read(reader).lineNumber); const disableCollapsing = true; - const currentItemIsCollapsed = !disableCollapsing && (cursorDist > 1 && this._collapsedInlineEditId.read(reader) === item.inlineEditCompletion.semanticId); + const currentItemIsCollapsed = !disableCollapsing && (cursorDist > 1 && this._collapsedInlineEditId.read(reader) === item.inlineEdit.semanticId); - const commands = item.inlineEditCompletion.inlineCompletion.source.inlineCompletions.commands; + const commands = item.inlineEdit.inlineCompletion.source.inlineCompletions.commands; const renderExplicitly = this._jumpedTo.read(reader); const inlineEdit = new InlineEdit(edit, currentItemIsCollapsed, renderExplicitly, commands ?? []); - return { kind: 'inlineEdit', inlineEdit, inlineCompletion: item.inlineEditCompletion, edits: [edit], cursorAtInlineEdit }; + return { kind: 'inlineEdit', inlineEdit, inlineCompletion: item.inlineEdit, edits: [edit], cursorAtInlineEdit }; } this._jumpedTo.set(false, undefined); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index 9063a9f4b3c..fb96c35487a 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -193,7 +193,8 @@ async function addRefAndCreateResult( itemsByHash.set(inlineCompletionItem.hash(), inlineCompletionItem); - if (context.triggerKind === InlineCompletionTriggerKind.Automatic) { + // Stop after first visible inline completion + if (!item.isInlineEdit && context.triggerKind === InlineCompletionTriggerKind.Automatic) { const minifiedEdit = inlineCompletionItem.toSingleTextEdit().removeCommonPrefix(new TextModelText(model)); if (!minifiedEdit.isEmpty) { shouldStop = true; diff --git a/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts b/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts index 7a35640c79b..74555b9a823 100644 --- a/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts +++ b/src/vs/editor/test/browser/services/decorationRenderOptions.test.ts @@ -130,7 +130,7 @@ suite('Decoration Render Options', () => { // single quote must always be escaped/encoded s.registerDecorationType('test', 'example', { gutterIconPath: URI.file('c:\\files\\foo\\b\'ar.png') }); - assertBackground('file:///c:/files/foo/b%27ar.png', 'vscode-file://vscode-app/c:/files/foo/b%27ar.png'); + assertBackground('file:///c:/files/foo/b\\000027ar.png', 'vscode-file://vscode-app/c:/files/foo/b\\000027ar.png'); s.removeDecorationType('example'); } else { // unix file path (used as string) @@ -140,12 +140,12 @@ suite('Decoration Render Options', () => { // single quote must always be escaped/encoded s.registerDecorationType('test', 'example', { gutterIconPath: URI.file('/Users/foo/b\'ar.png') }); - assertBackground('file:///Users/foo/b%27ar.png', 'vscode-file://vscode-app/Users/foo/b%27ar.png'); + assertBackground('file:///Users/foo/b\\000027ar.png', 'vscode-file://vscode-app/Users/foo/b\\000027ar.png'); s.removeDecorationType('example'); } s.registerDecorationType('test', 'example', { gutterIconPath: URI.parse('http://test/pa\'th') }); - assert(readStyleSheet(styleSheet).indexOf(`{background:url('http://test/pa%27th') center center no-repeat;}`) > 0); + assert(readStyleSheet(styleSheet).indexOf(`{background:url('http://test/pa\\000027th') center center no-repeat;}`) > 0); s.removeDecorationType('example'); }); }); diff --git a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts index c7644ea23e2..3c8b7d5b719 100644 --- a/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts +++ b/src/vs/platform/accessibilitySignal/browser/accessibilitySignalService.ts @@ -566,7 +566,7 @@ export class AccessibilitySignal { public static readonly codeActionTriggered = AccessibilitySignal.register({ name: localize('accessibilitySignals.codeActionRequestTriggered', 'Code Action Request Triggered'), - sound: Sound.requestSent, + sound: Sound.voiceRecordingStarted, legacySoundSettingsKey: 'audioCues.codeActionRequestTriggered', legacyAnnouncementSettingsKey: 'accessibility.alert.codeActionRequestTriggered', announcementMessage: localize('accessibility.signals.codeActionRequestTriggered', 'Code Action Request Triggered'), @@ -576,14 +576,7 @@ export class AccessibilitySignal { public static readonly codeActionApplied = AccessibilitySignal.register({ name: localize('accessibilitySignals.codeActionApplied', 'Code Action Applied'), legacySoundSettingsKey: 'audioCues.codeActionApplied', - sound: { - randomOneOf: [ - Sound.responseReceived1, - Sound.responseReceived2, - Sound.responseReceived3, - Sound.responseReceived4 - ] - }, + sound: Sound.voiceRecordingStopped, settingsKey: 'accessibility.signals.codeActionApplied' }); diff --git a/src/vs/platform/configuration/common/configurationModels.ts b/src/vs/platform/configuration/common/configurationModels.ts index 5b7a51dced2..400ae1033cb 100644 --- a/src/vs/platform/configuration/common/configurationModels.ts +++ b/src/vs/platform/configuration/common/configurationModels.ts @@ -296,6 +296,7 @@ export class ConfigurationModel implements IConfigurationModel { } export interface ConfigurationParseOptions { + skipUnregistered?: boolean; scopes?: ConfigurationScope[]; skipRestricted?: boolean; include?: string[]; @@ -428,14 +429,10 @@ export class ConfigurationModelParser { restricted.push(...result.restricted); } else { const propertySchema = configurationProperties[key]; - const scope = propertySchema ? typeof propertySchema.scope !== 'undefined' ? propertySchema.scope : ConfigurationScope.WINDOW : undefined; if (propertySchema?.restricted) { restricted.push(key); } - if (!options.exclude?.includes(key) /* Check exclude */ - && (options.include?.includes(key) /* Check include */ - || ((scope === undefined || options.scopes === undefined || options.scopes.includes(scope)) /* Check scopes */ - && !(options.skipRestricted && propertySchema?.restricted)))) /* Check restricted */ { + if (this.shouldInclude(key, propertySchema, options)) { raw[key] = properties[key]; } else { hasExcludedProperties = true; @@ -445,6 +442,31 @@ export class ConfigurationModelParser { return { raw, restricted, hasExcludedProperties }; } + private shouldInclude(key: string, propertySchema: IConfigurationPropertySchema | undefined, options: ConfigurationParseOptions): boolean { + if (options.exclude?.includes(key)) { + return false; + } + + if (options.include?.includes(key)) { + return true; + } + + if (options.skipRestricted && propertySchema?.restricted) { + return false; + } + + if (options.skipUnregistered && !propertySchema) { + return false; + } + + const scope = propertySchema ? typeof propertySchema.scope !== 'undefined' ? propertySchema.scope : ConfigurationScope.WINDOW : undefined; + if (scope === undefined || options.scopes === undefined) { + return true; + } + + return options.scopes.includes(scope); + } + private toOverrides(raw: any, conflictReporter: (message: string) => void): IOverrides[] { const overrides: IOverrides[] = []; for (const key of Object.keys(raw)) { diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index f2ad98bdd17..818021ff0c1 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -75,8 +75,6 @@ export interface NativeParsedArgs { 'disable-extensions'?: boolean; 'disable-extension'?: string[]; // undefined or array of 1 or more 'list-extensions'?: boolean; - 'download-extension'?: string[]; - 'location'?: string; 'show-versions'?: boolean; 'category'?: string; 'install-extension'?: string[]; // undefined or array of 1 or more diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 48809d5e894..379e327c646 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -97,7 +97,6 @@ export const OPTIONS: OptionDescriptions> = { 'list-extensions': { type: 'boolean', cat: 'e', description: localize('listExtensions', "List the installed extensions.") }, 'show-versions': { type: 'boolean', cat: 'e', description: localize('showVersions', "Show versions of installed extensions, when using --list-extensions.") }, 'category': { type: 'string', allowEmptyValue: true, cat: 'e', description: localize('category', "Filters installed extensions by provided category, when using --list-extensions."), args: 'category' }, - 'download-extension': { type: 'string[]', cat: 'e', args: 'ext-id', description: localize('downloadExtension', "Downloads the extension VSIX that can be installable. The argument is an identifier of an extension that is '${publisher}.${name}'. To download a specific version provide '@${version}'. For example: 'vscode.csharp@1.2.3'. Should provide '--location' to specify the location to download the VSIX.") }, 'install-extension': { type: 'string[]', cat: 'e', args: 'ext-id | path', description: localize('installExtension', "Installs or updates an extension. The argument is either an extension id or a path to a VSIX. The identifier of an extension is '${publisher}.${name}'. Use '--force' argument to update to latest version. To install a specific version provide '@${version}'. For example: 'vscode.csharp@1.2.3'.") }, 'pre-release': { type: 'boolean', cat: 'e', description: localize('install prerelease', "Installs the pre-release version of the extension, when using --install-extension") }, 'uninstall-extension': { type: 'string[]', cat: 'e', args: 'ext-id', description: localize('uninstallExtension', "Uninstalls an extension.") }, @@ -164,7 +163,6 @@ export const OPTIONS: OptionDescriptions> = { 'file-chmod': { type: 'boolean' }, 'install-builtin-extension': { type: 'string[]' }, 'force': { type: 'boolean' }, - 'location': { type: 'string' }, 'do-not-sync': { type: 'boolean' }, 'trace': { type: 'boolean' }, 'trace-category-filter': { type: 'string' }, diff --git a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts index 36a9901341a..69ba77c8c84 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementCLI.ts @@ -14,7 +14,6 @@ import { EXTENSION_IDENTIFIER_REGEX, IExtensionGalleryService, IExtensionInfo, I import { areSameExtensions, getExtensionId, getGalleryExtensionId, getIdAndVersion } from './extensionManagementUtil.js'; import { ExtensionType, EXTENSION_CATEGORIES, IExtensionManifest } from '../../extensions/common/extensions.js'; import { ILogger } from '../../log/common/log.js'; -import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; const notFound = (id: string) => localize('notFound', "Extension '{0}' not found.", id); @@ -29,7 +28,6 @@ export class ExtensionManagementCLI { protected readonly logger: ILogger, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, ) { } protected get location(): string | undefined { @@ -72,42 +70,6 @@ export class ExtensionManagementCLI { } } - public async downloadExtensions(extensions: string[], target: URI): Promise { - if (!extensions.length) { - return; - } - - this.logger.info(localize('downloadingExtensions', "Downloading extensions...")); - - const extensionsInfo: IExtensionInfo[] = []; - for (const extension of extensions) { - const [id, version] = getIdAndVersion(extension); - extensionsInfo.push({ id, version: version !== 'prerelease' ? version : undefined, preRelease: version === 'prerelease' }); - } - - try { - const galleryExtensions = await this.extensionGalleryService.getExtensions(extensionsInfo, CancellationToken.None); - const targetPlatform = await this.extensionManagementService.getTargetPlatform(); - await Promise.allSettled(extensionsInfo.map(async extensionInfo => { - const galleryExtension = galleryExtensions.find(e => areSameExtensions(e.identifier, { id: extensionInfo.id })); - if (!galleryExtension) { - this.logger.error(`${notFound(extensionInfo.id)}\n${useId}`); - return; - } - const compatible = await this.extensionGalleryService.getCompatibleExtension(galleryExtension, !!extensionInfo.hasPreRelease, targetPlatform); - try { - await this.extensionGalleryService.download(compatible ?? galleryExtension, this.uriIdentityService.extUri.joinPath(target, `${galleryExtension.identifier.id}-${galleryExtension.version}.vsix`), InstallOperation.None); - this.logger.info(localize('successDownload', "Extension '{0}' was successfully downloaded.", extensionInfo.id)); - } catch (error) { - this.logger.error(localize('error while downloading extension', "Error while downloading extension '{0}': {1}", extensionInfo.id, getErrorMessage(error))); - } - })); - } catch (error) { - this.logger.error(localize('error while downloading extensions', "Error while downloading extensions: {0}", getErrorMessage(error))); - throw error; - } - } - public async installExtensions(extensions: (string | URI)[], builtinExtensions: (string | URI)[], installOptions: InstallOptions, force: boolean): Promise { const failed: string[] = []; diff --git a/src/vs/platform/extensionManagement/node/extensionDownloader.ts b/src/vs/platform/extensionManagement/node/extensionDownloader.ts index 850dba7113d..436dc936f88 100644 --- a/src/vs/platform/extensionManagement/node/extensionDownloader.ts +++ b/src/vs/platform/extensionManagement/node/extensionDownloader.ts @@ -20,9 +20,10 @@ import { ExtensionKey, groupByExtension } from '../common/extensionManagementUti import { fromExtractError } from './extensionManagementUtil.js'; import { IExtensionSignatureVerificationService } from './extensionSignatureVerificationService.js'; import { TargetPlatform } from '../../extensions/common/extensions.js'; -import { IFileService, IFileStatWithMetadata } from '../../files/common/files.js'; +import { FileOperationResult, IFileService, IFileStatWithMetadata, toFileOperationResult } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; type RetryDownloadClassification = { owner: 'sandy081'; @@ -40,6 +41,7 @@ export class ExtensionsDownloader extends Disposable { private static readonly SignatureArchiveExtension = '.sigzip'; readonly extensionsDownloadDir: URI; + private readonly extensionsTrashDir: URI; private readonly cache: number; private readonly cleanUpPromise: Promise; @@ -49,10 +51,12 @@ export class ExtensionsDownloader extends Disposable { @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IExtensionSignatureVerificationService private readonly extensionSignatureVerificationService: IExtensionSignatureVerificationService, @ITelemetryService private readonly telemetryService: ITelemetryService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @ILogService private readonly logService: ILogService, ) { super(); this.extensionsDownloadDir = environmentService.extensionsDownloadLocation; + this.extensionsTrashDir = uriIdentityService.extUri.joinPath(environmentService.extensionsDownloadLocation, `.trash`); this.cache = 20; // Cache 20 downloaded VSIX files this.cleanUpPromise = this.cleanUp(); } @@ -132,7 +136,7 @@ export class ExtensionsDownloader extends Disposable { private async downloadSignatureArchive(extension: IGalleryExtension): Promise { try { - const location = joinPath(this.extensionsDownloadDir, `.${generateUuid()}`); + const location = joinPath(this.extensionsDownloadDir, `${this.getName(extension)}${ExtensionsDownloader.SignatureArchiveExtension}`); const attempts = await this.doDownload(extension, 'sigzip', async () => { await this.extensionGalleryService.downloadSignatureArchive(extension, location); try { @@ -224,7 +228,12 @@ export class ExtensionsDownloader extends Disposable { async delete(location: URI): Promise { await this.cleanUpPromise; - await this.fileService.del(location); + const trashRelativePath = this.uriIdentityService.extUri.relativePath(this.extensionsDownloadDir, location); + if (trashRelativePath) { + await this.fileService.move(location, this.uriIdentityService.extUri.joinPath(this.extensionsTrashDir, trashRelativePath), true); + } else { + await this.fileService.del(location); + } } private async cleanUp(): Promise { @@ -233,6 +242,15 @@ export class ExtensionsDownloader extends Disposable { this.logService.trace('Extension VSIX downloads cache dir does not exist'); return; } + + try { + await this.fileService.del(this.extensionsTrashDir, { recursive: true }); + } catch (error) { + if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { + this.logService.error(error); + } + } + const folderStat = await this.fileService.resolve(this.extensionsDownloadDir, { resolveMetadata: true }); if (folderStat.children) { const toDelete: URI[] = []; @@ -272,7 +290,7 @@ export class ExtensionsDownloader extends Disposable { } private getName(extension: IGalleryExtension): string { - return this.cache ? ExtensionKey.create(extension).toString().toLowerCase() : generateUuid(); + return ExtensionKey.create(extension).toString().toLowerCase(); } } diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index 4d68a00134e..ad7d55b3b85 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -332,6 +332,16 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi }, false, token); + + if (verificationStatus !== ExtensionSignatureVerificationCode.Success && this.environmentService.isBuilt) { + try { + await this.extensionsDownloader.delete(location); + } catch (e) { + /* Ignore */ + this.logService.warn(`Error while deleting the downloaded file`, location.toString(), getErrorMessage(e)); + } + } + return { local, verificationStatus }; } catch (error) { try { @@ -352,6 +362,13 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi const { location, verificationStatus } = await this.extensionsDownloader.download(extension, operation, verifySignature, clientTargetPlatform); if (verificationStatus !== ExtensionSignatureVerificationCode.Success && verifySignature && this.environmentService.isBuilt && !isLinux) { + try { + await this.extensionsDownloader.delete(location); + } catch (e) { + /* Ignore */ + this.logService.warn(`Error while deleting the downloaded file`, location.toString(), getErrorMessage(e)); + } + if (!extension.isSigned) { throw new ExtensionManagementError(nls.localize('not signed', "Extension is not signed."), ExtensionManagementErrorCode.PackageNotSigned); } diff --git a/src/vs/platform/extensionManagement/test/node/extensionDownloader.test.ts b/src/vs/platform/extensionManagement/test/node/extensionDownloader.test.ts index aa437e000a6..366e13804d1 100644 --- a/src/vs/platform/extensionManagement/test/node/extensionDownloader.test.ts +++ b/src/vs/platform/extensionManagement/test/node/extensionDownloader.test.ts @@ -22,6 +22,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IUriIdentityService } from '../../../uriIdentity/common/uriIdentity.js'; +import { UriIdentityService } from '../../../uriIdentity/common/uriIdentityService.js'; const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); @@ -67,6 +69,7 @@ suite('ExtensionDownloader Tests', () => { instantiationService.stub(ILogService, logService); instantiationService.stub(IFileService, fileService); instantiationService.stub(ILogService, logService); + instantiationService.stub(IUriIdentityService, disposables.add(new UriIdentityService(fileService))); instantiationService.stub(INativeEnvironmentService, { extensionsDownloadLocation: joinPath(ROOT, 'CachedExtensionVSIXs') }); instantiationService.stub(IExtensionGalleryService, { async download(extension, location, operation) { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 3c368bf8774..b8d13ce3f00 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -238,10 +238,6 @@ const _allApiProposals = { languageStatusText: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.languageStatusText.d.ts', }, - lmTools: { - proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.lmTools.d.ts', - version: 15 - }, mappedEditsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts', }, @@ -398,6 +394,9 @@ const _allApiProposals = { tunnels: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tunnels.d.ts', }, + valueSelectionInQuickPick: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts', + }, workspaceTrust: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts', } diff --git a/src/vs/platform/log/common/log.ts b/src/vs/platform/log/common/log.ts index 2471e0573d8..e503417a554 100644 --- a/src/vs/platform/log/common/log.ts +++ b/src/vs/platform/log/common/log.ts @@ -259,6 +259,13 @@ export abstract class AbstractLogger extends Disposable implements ILogger { return this.level !== LogLevel.Off && this.level <= level; } + protected canLog(level: LogLevel): boolean { + if (this._store.isDisposed) { + return false; + } + return this.checkLogLevel(level); + } + abstract trace(message: string, ...args: any[]): void; abstract debug(message: string, ...args: any[]): void; abstract info(message: string, ...args: any[]): void; @@ -269,8 +276,6 @@ export abstract class AbstractLogger extends Disposable implements ILogger { export abstract class AbstractMessageLogger extends AbstractLogger implements ILogger { - protected abstract log(level: LogLevel, message: string): void; - constructor(private readonly logAlways?: boolean) { super(); } @@ -280,32 +285,31 @@ export abstract class AbstractMessageLogger extends AbstractLogger implements IL } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { this.log(LogLevel.Trace, format([message, ...args], true)); } } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { this.log(LogLevel.Debug, format([message, ...args])); } } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { this.log(LogLevel.Info, format([message, ...args])); } } warn(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { this.log(LogLevel.Warning, format([message, ...args])); } } error(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { - + if (this.canLog(LogLevel.Error)) { if (message instanceof Error) { const array = Array.prototype.slice.call(arguments) as any[]; array[0] = message.stack; @@ -317,6 +321,8 @@ export abstract class AbstractMessageLogger extends AbstractLogger implements IL } flush(): void { } + + protected abstract log(level: LogLevel, message: string): void; } @@ -331,7 +337,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { if (this.useColors) { console.log(`\x1b[90m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -341,7 +347,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { if (this.useColors) { console.log(`\x1b[90m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -351,7 +357,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { if (this.useColors) { console.log(`\x1b[90m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -361,7 +367,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { if (this.useColors) { console.warn(`\x1b[93m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -371,7 +377,7 @@ export class ConsoleMainLogger extends AbstractLogger implements ILogger { } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { if (this.useColors) { console.error(`\x1b[91m[main ${now()}]\x1b[0m`, message, ...args); } else { @@ -394,7 +400,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { if (this.useColors) { console.log('%cTRACE', 'color: #888', message, ...args); } else { @@ -404,7 +410,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { if (this.useColors) { console.log('%cDEBUG', 'background: #eee; color: #888', message, ...args); } else { @@ -414,7 +420,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { if (this.useColors) { console.log('%c INFO', 'color: #33f', message, ...args); } else { @@ -424,7 +430,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { if (this.useColors) { console.log('%c WARN', 'color: #993', message, ...args); } else { @@ -434,7 +440,7 @@ export class ConsoleLogger extends AbstractLogger implements ILogger { } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { if (this.useColors) { console.log('%c ERR', 'color: #f33', message, ...args); } else { @@ -457,31 +463,31 @@ export class AdapterLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { this.adapter.log(LogLevel.Trace, [this.extractMessage(message), ...args]); } } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { this.adapter.log(LogLevel.Debug, [this.extractMessage(message), ...args]); } } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { this.adapter.log(LogLevel.Info, [this.extractMessage(message), ...args]); } } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { this.adapter.log(LogLevel.Warning, [this.extractMessage(message), ...args]); } } error(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { this.adapter.log(LogLevel.Error, [this.extractMessage(message), ...args]); } } @@ -491,7 +497,7 @@ export class AdapterLogger extends AbstractLogger implements ILogger { return msg; } - return toErrorMessage(msg, this.checkLogLevel(LogLevel.Trace)); + return toErrorMessage(msg, this.canLog(LogLevel.Trace)); } flush(): void { diff --git a/src/vs/platform/log/node/spdlogLog.ts b/src/vs/platform/log/node/spdlogLog.ts index ef99c32831a..76275056222 100644 --- a/src/vs/platform/log/node/spdlogLog.ts +++ b/src/vs/platform/log/node/spdlogLog.ts @@ -111,9 +111,9 @@ export class SpdLogLogger extends AbstractMessageLogger implements ILogger { override flush(): void { if (this._logger) { - this._logger.flush(); + this.flushLogger(); } else { - this._loggerCreationPromise.then(() => this.flush()); + this._loggerCreationPromise.then(() => this.flushLogger()); } } @@ -126,6 +126,12 @@ export class SpdLogLogger extends AbstractMessageLogger implements ILogger { super.dispose(); } + private flushLogger(): void { + if (this._logger) { + this._logger.flush(); + } + } + private disposeLogger(): void { if (this._logger) { this._logger.drop(); diff --git a/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts b/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts index 91721dc7bd5..eb72191f928 100644 --- a/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts +++ b/src/vs/platform/telemetry/test/common/telemetryLogAppender.test.ts @@ -21,31 +21,31 @@ class TestTelemetryLogger extends AbstractLogger implements ILogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { this.logs.push(message + JSON.stringify(args)); } } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { this.logs.push(message); } } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { this.logs.push(message); } } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { this.logs.push(message.toString()); } } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { this.logs.push(message); } } diff --git a/src/vs/platform/theme/browser/iconsStyleSheet.ts b/src/vs/platform/theme/browser/iconsStyleSheet.ts index 9133d54c5c7..42c2d3b3bab 100644 --- a/src/vs/platform/theme/browser/iconsStyleSheet.ts +++ b/src/vs/platform/theme/browser/iconsStyleSheet.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { asCSSPropertyValue, asCSSUrl } from '../../../base/browser/cssValue.js'; +import * as css from '../../../base/browser/cssValue.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; @@ -11,7 +11,7 @@ import { getIconRegistry, IconContribution, IconFontDefinition } from '../common import { IProductIconTheme, IThemeService } from '../common/themeService.js'; export interface IIconsStyleSheet extends IDisposable { - getCSS(): string; + getCSS(): css.CssFragment; readonly onDidChange: Event; } @@ -28,12 +28,12 @@ export function getIconsStyleSheet(themeService: IThemeService | undefined): IIc return { dispose: () => disposable.dispose(), onDidChange: onDidChangeEmmiter.event, - getCSS() { + getCSS(): css.CssFragment { const productIconTheme = themeService ? themeService.getProductIconTheme() : new UnthemedProductIconTheme(); const usedFontIds: { [id: string]: IconFontDefinition } = {}; - const rules: string[] = []; - const rootAttribs: string[] = []; + const rules = new css.Builder(); + const rootAttribs = new css.Builder(); for (const contribution of iconRegistry.getIcons()) { const definition = productIconTheme.getIcon(contribution); if (!definition) { @@ -41,30 +41,34 @@ export function getIconsStyleSheet(themeService: IThemeService | undefined): IIc } const fontContribution = definition.font; - const fontFamilyVar = `--vscode-icon-${contribution.id}-font-family`; - const contentVar = `--vscode-icon-${contribution.id}-content`; + const fontFamilyVar = css.inline`--vscode-icon-${css.className(contribution.id)}-font-family`; + const contentVar = css.inline`--vscode-icon-${css.className(contribution.id)}-content`; if (fontContribution) { usedFontIds[fontContribution.id] = fontContribution.definition; rootAttribs.push( - `${fontFamilyVar}: ${asCSSPropertyValue(fontContribution.id)};`, - `${contentVar}: '${definition.fontCharacter}';`, + css.inline`${fontFamilyVar}: ${css.stringValue(fontContribution.id)};`, + css.inline`${contentVar}: ${css.stringValue(definition.fontCharacter)};`, ); - rules.push(`.codicon-${contribution.id}:before { content: '${definition.fontCharacter}'; font-family: ${asCSSPropertyValue(fontContribution.id)}; }`); + rules.push(css.inline`.codicon-${css.className(contribution.id)}:before { content: ${css.stringValue(definition.fontCharacter)}; font-family: ${css.stringValue(fontContribution.id)}; }`); } else { - rootAttribs.push(`${contentVar}: '${definition.fontCharacter}'; ${fontFamilyVar}: 'codicon';`); - rules.push(`.codicon-${contribution.id}:before { content: '${definition.fontCharacter}'; }`); + rootAttribs.push(css.inline`${contentVar}: ${css.stringValue(definition.fontCharacter)}; ${fontFamilyVar}: 'codicon';`); + rules.push(css.inline`.codicon-${css.className(contribution.id)}:before { content: ${css.stringValue(definition.fontCharacter)}; }`); } } for (const id in usedFontIds) { const definition = usedFontIds[id]; - const fontWeight = definition.weight ? `font-weight: ${definition.weight};` : ''; - const fontStyle = definition.style ? `font-style: ${definition.style};` : ''; - const src = definition.src.map(l => `${asCSSUrl(l.location)} format('${l.format}')`).join(', '); - rules.push(`@font-face { src: ${src}; font-family: ${asCSSPropertyValue(id)};${fontWeight}${fontStyle} font-display: block; }`); + const fontWeight = definition.weight ? css.inline`font-weight: ${css.value(definition.weight)};` : css.inline``; + const fontStyle = definition.style ? css.inline`font-style: ${css.value(definition.style)};` : css.inline``; + + const src = new css.Builder(); + for (const l of definition.src) { + src.push(css.inline`${css.asCSSUrl(l.location)} format(${css.stringValue(l.format)})`); + } + rules.push(css.inline`@font-face { src: ${src.join(', ')}; font-family: ${css.stringValue(id)};${fontWeight}${fontStyle} font-display: block; }`); } - rules.push(`:root { ${rootAttribs.join(' ')} }`); + rules.push(css.inline`:root { ${rootAttribs.join(' ')} }`); return rules.join('\n'); } diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index cd1091150db..42517aa71b3 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -276,7 +276,7 @@ class ServerLogger extends AbstractLogger { } trace(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Trace)) { + if (this.canLog(LogLevel.Trace)) { if (this.useColors) { console.log(`\x1b[90m[${now()}]\x1b[0m`, message, ...args); } else { @@ -286,7 +286,7 @@ class ServerLogger extends AbstractLogger { } debug(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Debug)) { + if (this.canLog(LogLevel.Debug)) { if (this.useColors) { console.log(`\x1b[90m[${now()}]\x1b[0m`, message, ...args); } else { @@ -296,7 +296,7 @@ class ServerLogger extends AbstractLogger { } info(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Info)) { + if (this.canLog(LogLevel.Info)) { if (this.useColors) { console.log(`\x1b[90m[${now()}]\x1b[0m`, message, ...args); } else { @@ -306,7 +306,7 @@ class ServerLogger extends AbstractLogger { } warn(message: string | Error, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Warning)) { + if (this.canLog(LogLevel.Warning)) { if (this.useColors) { console.warn(`\x1b[93m[${now()}]\x1b[0m`, message, ...args); } else { @@ -316,7 +316,7 @@ class ServerLogger extends AbstractLogger { } error(message: string, ...args: any[]): void { - if (this.checkLogLevel(LogLevel.Error)) { + if (this.canLog(LogLevel.Error)) { if (this.useColors) { console.error(`\x1b[91m[${now()}]\x1b[0m`, message, ...args); } else { diff --git a/src/vs/workbench/api/browser/mainThreadCLICommands.ts b/src/vs/workbench/api/browser/mainThreadCLICommands.ts index eaf95ea867c..be73afd9fd4 100644 --- a/src/vs/workbench/api/browser/mainThreadCLICommands.ts +++ b/src/vs/workbench/api/browser/mainThreadCLICommands.ts @@ -18,7 +18,6 @@ import { ServiceCollection } from '../../../platform/instantiation/common/servic import { ILabelService } from '../../../platform/label/common/label.js'; import { AbstractMessageLogger, ILogger, LogLevel } from '../../../platform/log/common/log.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; -import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; import { IOpenWindowOptions, IWindowOpenable } from '../../../platform/window/common/window.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; import { IExtensionManagementServerService } from '../../services/extensionManagement/common/extensionManagement.js'; @@ -104,12 +103,11 @@ class RemoteExtensionManagementCLI extends ExtensionManagementCLI { logger: ILogger, @IExtensionManagementService extensionManagementService: IExtensionManagementService, @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, - @IUriIdentityService uriIdentityService: IUriIdentityService, @ILabelService labelService: ILabelService, @IWorkbenchEnvironmentService envService: IWorkbenchEnvironmentService, @IExtensionManifestPropertiesService private readonly _extensionManifestPropertiesService: IExtensionManifestPropertiesService, ) { - super(logger, extensionManagementService, extensionGalleryService, uriIdentityService); + super(logger, extensionManagementService, extensionGalleryService); const remoteAuthority = envService.remoteAuthority; this._location = remoteAuthority ? labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority) : undefined; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 5d2c2df223f..cf451338375 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -25,7 +25,7 @@ import { IChatWidgetService } from '../../contrib/chat/browser/chat.js'; import { ChatInputPart } from '../../contrib/chat/browser/chatInputPart.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/contrib/chatDynamicVariables.js'; import { ChatAgentLocation, IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/chatAgents.js'; -import { IChatEditingService } from '../../contrib/chat/common/chatEditingService.js'; +import { IChatEditingService, IChatRelatedFileProviderMetadata } from '../../contrib/chat/common/chatEditingService.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/chatParserTypes.js'; import { ChatRequestParser } from '../../contrib/chat/common/chatRequestParser.js'; import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatProgress, IChatService, IChatTask, IChatWarningMessage } from '../../contrib/chat/common/chatService.js'; @@ -349,8 +349,9 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._chatParticipantDetectionProviders.deleteAndDispose(handle); } - $registerRelatedFilesProvider(handle: number): void { + $registerRelatedFilesProvider(handle: number, metadata: IChatRelatedFileProviderMetadata): void { this._chatRelatedFilesProviders.set(handle, this._chatEditingService.registerRelatedFilesProvider(handle, { + description: metadata.description, provideRelatedFiles: async (request, token) => { return (await this._proxy.$provideRelatedFiles(handle, request, token))?.map((v) => ({ uri: URI.from(v.uri), description: v.description })) ?? []; } diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index e45e567775c..3fa10161b9a 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -132,7 +132,8 @@ const defaultConfigurationExtPoint = ExtensionsRegistry.registerExtensionPoint { @@ -195,7 +196,8 @@ const configurationExtPoint = ExtensionsRegistry.registerExtensionPoint = new ExtensionIdentifierMap(); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 23a7284f894..a4532ed13ec 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -52,7 +52,7 @@ import { IRevealOptions, ITreeItem, IViewBadge } from '../../common/views.js'; import { CallHierarchyItem } from '../../contrib/callHierarchy/common/callHierarchy.js'; import { ChatAgentLocation, IChatAgentMetadata, IChatAgentRequest, IChatAgentResult, IChatWelcomeMessageContent } from '../../contrib/chat/common/chatAgents.js'; import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common/chatCodeMapperService.js'; -import { IChatRelatedFile, IChatRequestDraft } from '../../contrib/chat/common/chatEditingService.js'; +import { IChatRelatedFile, IChatRelatedFileProviderMetadata as IChatRelatedFilesProviderMetadata, IChatRequestDraft } from '../../contrib/chat/common/chatEditingService.js'; import { IChatProgressHistoryResponseContent } from '../../contrib/chat/common/chatModel.js'; import { IChatContentInlineReference, IChatFollowup, IChatProgress, IChatResponseErrorDetails, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService.js'; import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolverProgress } from '../../contrib/chat/common/chatVariables.js'; @@ -1275,7 +1275,7 @@ export interface MainThreadChatAgentsShape2 extends IDisposable { $registerAgent(handle: number, extension: ExtensionIdentifier, id: string, metadata: IExtensionChatAgentMetadata, dynamicProps: IDynamicChatAgentProps | undefined): void; $registerChatParticipantDetectionProvider(handle: number): void; $unregisterChatParticipantDetectionProvider(handle: number): void; - $registerRelatedFilesProvider(handle: number): void; + $registerRelatedFilesProvider(handle: number, metadata: IChatRelatedFilesProviderMetadata): void; $unregisterRelatedFilesProvider(handle: number): void; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; $unregisterAgentCompletionsProvider(handle: number, id: string): void; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 14c1e0e027d..d581fe4081f 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -369,7 +369,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS registerRelatedFilesProvider(extension: IExtensionDescription, provider: vscode.ChatRelatedFilesProvider, metadata: vscode.ChatRelatedFilesProviderMetadata): vscode.Disposable { const handle = ExtHostChatAgents2._relatedFilesProviderIdPool++; this._relatedFilesProviders.set(handle, new ExtHostRelatedFilesProvider(extension, provider)); - this._proxy.$registerRelatedFilesProvider(handle); + this._proxy.$registerRelatedFilesProvider(handle, metadata); return toDisposable(() => { this._relatedFilesProviders.delete(handle); this._proxy.$unregisterRelatedFilesProvider(handle); diff --git a/src/vs/workbench/api/common/extHostQuickOpen.ts b/src/vs/workbench/api/common/extHostQuickOpen.ts index a5194dce8ef..cb17214f2a4 100644 --- a/src/vs/workbench/api/common/extHostQuickOpen.ts +++ b/src/vs/workbench/api/common/extHostQuickOpen.ts @@ -283,6 +283,7 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx private _busy = false; private _ignoreFocusOut = true; private _value = ''; + private _valueSelection: readonly [number, number] | undefined = undefined; private _placeholder: string | undefined; private _buttons: QuickInputButton[] = []; private _handlesToButtons = new Map(); @@ -367,6 +368,15 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx this.update({ value }); } + get valueSelection() { + return this._valueSelection; + } + + set valueSelection(valueSelection: readonly [number, number] | undefined) { + this._valueSelection = valueSelection; + this.update({ valueSelection }); + } + get placeholder() { return this._placeholder; } @@ -713,7 +723,6 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx private _password = false; private _prompt: string | undefined; - private _valueSelection: readonly [number, number] | undefined; private _validationMessage: string | InputBoxValidationMessage | undefined; constructor(extension: IExtensionDescription, onDispose: () => void) { @@ -739,15 +748,6 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx this.update({ prompt }); } - get valueSelection() { - return this._valueSelection; - } - - set valueSelection(valueSelection: readonly [number, number] | undefined) { - this._valueSelection = valueSelection; - this.update({ valueSelection }); - } - get validationMessage() { return this._validationMessage; } diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 470ecc8c091..21ef42df1c1 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -708,6 +708,12 @@ const configuration: IConfigurationNode = { 'type': 'boolean', 'description': localize('accessibility.replEditor.readLastExecutedOutput', "Controls whether the output from an execution in the native REPL will be announced."), 'default': true, + }, + 'accessibility.replEditor.autoFocusReplExecution': { + type: 'string', + enum: ['none', 'input', 'lastExecution'], + default: 'lastExecution', + description: localize('replEditor.autoFocusAppendedCell', "Control whether focus should automatically be sent to the REPL when code is executed."), } } }; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index ffdd147cf83..7063ffe744c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isElectron } from '../../../../../base/common/platform.js'; +import { dirname } from '../../../../../base/common/resources.js'; import { compare } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -63,7 +64,7 @@ export function registerChatContextActions() { /** * We fill the quickpick with these types, and enable some quick access providers */ -type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem | IImageQuickPickItem | IVariableQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem | IScreenShotQuickPickItem; +type IAttachmentQuickPickItem = ICommandVariableQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem | IImageQuickPickItem | IVariableQuickPickItem | IOpenEditorsQuickPickItem | ISearchResultsQuickPickItem | IScreenShotQuickPickItem | IRelatedFilesQuickPickItem; /** * These are the types that we can get out of the quick pick @@ -110,6 +111,19 @@ function isScreenshotQuickPickItem(obj: unknown): obj is IScreenShotQuickPickIte && (obj as IScreenShotQuickPickItem).kind === 'screenshot'); } +function isRelatedFileQuickPickItem(obj: unknown): obj is IRelatedFilesQuickPickItem { + return ( + typeof obj === 'object' + && (obj as IRelatedFilesQuickPickItem).kind === 'related-files' + ); +} + +interface IRelatedFilesQuickPickItem extends IQuickPickItem { + kind: 'related-files'; + id: string; + label: string; +} + interface IImageQuickPickItem extends IQuickPickItem { kind: 'image'; id: string; @@ -384,7 +398,7 @@ export class AttachContextAction extends Action2 { `:${item.range.startLineNumber}`); } - private async _attachContext(widget: IChatWidget, commandService: ICommandService, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { + private async _attachContext(widget: IChatWidget, quickInputService: IQuickInputService, commandService: ICommandService, clipboardService: IClipboardService, editorService: IEditorService, labelService: ILabelService, viewsService: IViewsService, chatEditingService: IChatEditingService | undefined, hostService: IHostService, isInBackground?: boolean, ...picks: IChatContextQuickPickItem[]) { const toAttach: IChatRequestVariableEntry[] = []; for (const pick of picks) { if (isISymbolQuickPickItem(pick) && pick.symbol) { @@ -462,6 +476,37 @@ export class AttachContextAction extends Action2 { }); } } + } else if (isRelatedFileQuickPickItem(pick)) { + // Get all provider results and show them in a second tier picker + const chatSessionId = widget.viewModel?.sessionId; + if (!chatSessionId || !chatEditingService) { + continue; + } + const relatedFiles = await chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), CancellationToken.None); + if (!relatedFiles) { + continue; + } + const attachments = widget.attachmentModel.getAttachmentIDs(); + const itemsPromise = chatEditingService.getRelatedFiles(chatSessionId, widget.getInput(), CancellationToken.None) + .then((files) => (files ?? []).reduce<((IQuickPickItem & { value: URI }) | IQuickPickSeparator)[]>((acc, cur) => { + acc.push({ type: 'separator', label: cur.group }); + const workingSet = chatEditingService.currentEditingSessionObs.get()?.workingSet; + for (const file of cur.files) { + acc.push({ + type: 'item', + label: labelService.getUriBasenameLabel(file.uri), + description: labelService.getUriLabel(dirname(file.uri), { relative: true }), + value: file.uri, + disabled: workingSet?.has(file.uri) || attachments.has(this._getFileContextId({ resource: file.uri })), + picked: true + }); + } + return acc; + }, [])); + const selectedFiles = await quickInputService.pick(itemsPromise, { placeHolder: localize('relatedFiles', 'Add related files to your working set'), canPickMany: true }); + for (const file of selectedFiles ?? []) { + chatEditingService?.currentEditingSessionObs.get()?.addFileToWorkingSet(file.value); + } } else if (isScreenshotQuickPickItem(pick)) { const blob = await hostService.getScreenshot(); if (blob) { @@ -654,6 +699,14 @@ export class AttachContextAction extends Action2 { }); } } else if (context.showFilesOnly) { + if (chatEditingService?.hasRelatedFilesProviders() && (widget.getInput() || chatEditingService.currentEditingSessionObs.get()?.workingSet.size)) { + quickPickItems.push({ + kind: 'related-files', + id: 'related-files', + label: localize('chatContext.relatedFiles', 'Related Files'), + iconClass: ThemeIcon.asClassName(Codicon.sparkle), + }); + } if (editorService.editors.filter(e => e instanceof FileEditorInput || e instanceof DiffEditorInput || e instanceof UntitledTextEditorInput).length > 0) { quickPickItems.push({ kind: 'open-editors', @@ -698,7 +751,7 @@ export class AttachContextAction extends Action2 { if (!clipboardService) { return; } - this._attachContext(widget, commandService, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, isBackgroundAccept, item); + this._attachContext(widget, quickInputService, commandService, clipboardService, editorService, labelService, viewsService, chatEditingService, hostService, isBackgroundAccept, item); if (isQuickChat(widget)) { quickChatService.open(); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index b65aa16fb60..43d23d8253e 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -78,6 +78,7 @@ import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } f import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; import { Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js'; import { ChatEditorOverlayController } from './chatEditorOverlay.js'; +import { ChatRelatedFilesContribution } from './contrib/chatInputRelatedFilesContrib.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -312,6 +313,7 @@ registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, Langu registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatCommandCenterRendering.ID, ChatCommandCenterRendering, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitContextContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatRelatedFilesContribution.ID, ChatRelatedFilesContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatEditorSaving.ID, ChatEditorSaving, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts index 605889e6b35..9e1d1b3cd11 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatAttachmentsContentPart.ts @@ -9,13 +9,17 @@ import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Range } from '../../../../../editor/common/core/range.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; import { localize } from '../../../../../nls.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ITextEditorOptions } from '../../../../../platform/editor/common/editor.js'; import { FileKind, IFileService } from '../../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IOpenerService, OpenInternalOptions } from '../../../../../platform/opener/common/opener.js'; +import { FolderThemeIcon, IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { ResourceLabels } from '../../../../browser/labels.js'; +import { revealInsideBarCommand } from '../../../files/browser/fileActions.contribution.js'; import { IChatRequestVariableEntry } from '../../common/chatModel.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../common/chatService.js'; @@ -33,7 +37,9 @@ export class ChatAttachmentsContentPart extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IOpenerService private readonly openerService: IOpenerService, @IHoverService private readonly hoverService: IHoverService, - @IFileService private readonly fileService: IFileService + @IFileService private readonly fileService: IFileService, + @ICommandService private readonly commandService: ICommandService, + @IThemeService private readonly themeService: IThemeService ) { super(); @@ -47,9 +53,9 @@ export class ChatAttachmentsContentPart extends Disposable { const hoverDelegate = this.attachedContextDisposables.add(createInstantHoverDelegate()); this.variables.forEach(async (attachment) => { - const file = URI.isUri(attachment.value) ? attachment.value : attachment.value && typeof attachment.value === 'object' && 'uri' in attachment.value && URI.isUri(attachment.value.uri) ? attachment.value.uri : undefined; + const resource = URI.isUri(attachment.value) ? attachment.value : attachment.value && typeof attachment.value === 'object' && 'uri' in attachment.value && URI.isUri(attachment.value.uri) ? attachment.value.uri : undefined; const range = attachment.value && typeof attachment.value === 'object' && 'range' in attachment.value && Range.isIRange(attachment.value.range) ? attachment.value.range : undefined; - if (file && attachment.isFile && this.workingSet.find(entry => entry.toString() === file.toString())) { + if (resource && attachment.isFile && this.workingSet.find(entry => entry.toString() === resource.toString())) { // Don't render attachment if it's in the working set return; } @@ -63,9 +69,9 @@ export class ChatAttachmentsContentPart extends Disposable { let ariaLabel: string | undefined; - if (file && attachment.isFile) { - const fileBasename = basename(file.path); - const fileDirname = dirname(file.path); + if (resource && (attachment.isFile || attachment.isDirectory)) { + const fileBasename = basename(resource.path); + const fileDirname = dirname(resource.path); const friendlyName = `${fileBasename} ${fileDirname}`; if (isAttachmentOmitted) { @@ -76,12 +82,20 @@ export class ChatAttachmentsContentPart extends Disposable { ariaLabel = range ? localize('chat.fileAttachmentWithRange3', "Attached: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment3', "Attached: {0}.", friendlyName); } - label.setFile(file, { - fileKind: FileKind.FILE, + const fileOptions = { hidePath: true, - range, title: correspondingContentReference?.options?.status?.description + }; + label.setFile(resource, attachment.isFile ? { + ...fileOptions, + fileKind: FileKind.FILE, + range, + } : { + ...fileOptions, + fileKind: FileKind.FOLDER, + icon: !this.themeService.getFileIconTheme().hasFolderIcons ? FolderThemeIcon : undefined }); + } else if (attachment.isImage) { ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name); @@ -134,19 +148,16 @@ export class ChatAttachmentsContentPart extends Disposable { } } - if (file) { + if (resource) { widget.style.cursor = 'pointer'; if (!this.attachedContextDisposables.isDisposed) { this.attachedContextDisposables.add(dom.addDisposableListener(widget, dom.EventType.CLICK, async (e: MouseEvent) => { dom.EventHelper.stop(e, true); - this.openerService.open( - file, - { - fromUserGesture: true, - editorOptions: { - selection: range - } as any - }); + if (attachment.isDirectory) { + this.openResource(resource, true); + } else { + this.openResource(resource, false, range); + } })); } } @@ -156,6 +167,24 @@ export class ChatAttachmentsContentPart extends Disposable { }); } + private openResource(resource: URI, isDirectory: true): void; + private openResource(resource: URI, isDirectory: false, range: IRange | undefined): void; + private openResource(resource: URI, isDirectory?: boolean, range?: IRange): void { + if (isDirectory) { + // Reveal Directory in explorer + this.commandService.executeCommand(revealInsideBarCommand.id, resource); + return; + } + + // Open file in editor + const openTextEditorOptions: ITextEditorOptions | undefined = range ? { selection: range } : undefined; + const options: OpenInternalOptions = { + fromUserGesture: true, + editorOptions: openTextEditorOptions, + }; + this.openerService.open(resource, options); + } + // Helper function to create and replace image private async createImageElements(buffer: ArrayBuffer | Uint8Array, widget: HTMLElement, hoverElement: HTMLElement) { const blob = new Blob([buffer], { type: 'image/png' }); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts index 839bc440386..6e5e2f3eef6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts @@ -48,6 +48,7 @@ const $ = dom.$; export interface IChatReferenceListItem extends IChatContentReference { title?: string; + description?: string; state?: WorkingSetEntryState; } @@ -382,12 +383,12 @@ class CollapsibleListRenderer implements IListRenderer { + const contexts = await this.getAttachContext(e); if (contexts.length === 0) { return; } @@ -94,17 +102,7 @@ export class ChatDragAndDrop extends Themable { e.stopPropagation(); e.preventDefault(); - // Make sure to attach only new contexts - const currentContextIds = this.inputPart.attachmentModel.getAttachmentIDs(); - const filteredContext = []; - for (const context of contexts) { - if (!currentContextIds.has(context.id)) { - currentContextIds.add(context.id); - filteredContext.push(context); - } - } - - this.inputPart.attachmentModel.addContext(...filteredContext); + this.inputPart.attachmentModel.addContext(...contexts); } private updateDropFeedback(e: DragEvent, dropType: ChatDragAndDropType | undefined): void { @@ -116,6 +114,36 @@ export class ChatDragAndDrop extends Themable { this.setOverlay(dropType); } + private guessDropType(e: DragEvent): ChatDragAndDropType | undefined { + // This is an esstimation based on the datatransfer types/items + if (this.isImageDnd(e)) { + return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? ChatDragAndDropType.IMAGE : undefined; + } else if (containsDragType(e, DataTransfers.FILES)) { + return ChatDragAndDropType.FILE_EXTERNAL; + } else if (containsDragType(e, DataTransfers.INTERNAL_URI_LIST)) { + return ChatDragAndDropType.FILE_INTERNAL; + } else if (containsDragType(e, Mimes.uriList)) { + return ChatDragAndDropType.FOLDER; + } + + return undefined; + } + + private isDragEventSupported(e: DragEvent): boolean { + // if guessed drop type is undefined, it means the drop is not supported + const dropType = this.guessDropType(e); + return dropType !== undefined; + } + + private getDropTypeName(type: ChatDragAndDropType): string { + switch (type) { + case ChatDragAndDropType.FILE_INTERNAL: return localize('file', 'File'); + case ChatDragAndDropType.FILE_EXTERNAL: return localize('file', 'File'); + case ChatDragAndDropType.FOLDER: return localize('folder', 'Folder'); + case ChatDragAndDropType.IMAGE: return localize('image', 'Image'); + } + } + private isImageDnd(e: DragEvent): boolean { // Image detection should not have false positives, only false negatives are allowed if (containsDragType(e, 'image')) { @@ -139,42 +167,18 @@ export class ChatDragAndDrop extends Themable { return false; } - private guessDropType(e: DragEvent): ChatDragAndDropType | undefined { - // This is an esstimation based on the datatransfer types/items - if (this.isImageDnd(e)) { - return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? ChatDragAndDropType.IMAGE : undefined; - } else if (containsDragType(e, DataTransfers.FILES, DataTransfers.INTERNAL_URI_LIST)) { - return ChatDragAndDropType.FILE; - } - - return undefined; - } - - private isDragEventSupported(e: DragEvent): boolean { - // if guessed drop type is undefined, it means the drop is not supported - const dropType = this.guessDropType(e); - return dropType !== undefined; - } - - private getDropTypeName(type: ChatDragAndDropType): string { - switch (type) { - case ChatDragAndDropType.FILE: return localize('file', 'File'); - case ChatDragAndDropType.IMAGE: return localize('image', 'Image'); - } - } - - private getAttachContext(e: DragEvent): IChatRequestVariableEntry[] { + private async getAttachContext(e: DragEvent): Promise { if (!this.isDragEventSupported(e)) { return []; } const data = extractEditorsDropData(e); - return coalesce(data.map(editorInput => { + return coalesce(await Promise.all(data.map(editorInput => { return this.resolveAttachContext(editorInput); - })); + }))); } - private resolveAttachContext(editorInput: IDraggedResourceEditorInput): IChatRequestVariableEntry | undefined { + private async resolveAttachContext(editorInput: IDraggedResourceEditorInput): Promise { // Image const imageContext = getImageAttachContext(editorInput); if (imageContext) { @@ -182,7 +186,26 @@ export class ChatDragAndDrop extends Themable { } // File - return getEditorAttachContext(editorInput); + return await this.getEditorAttachContext(editorInput); + } + + private async getEditorAttachContext(editor: EditorInput | IDraggedResourceEditorInput): Promise { + if (!editor.resource) { + return undefined; + } + + let stat; + try { + stat = await this.fileService.stat(editor.resource); + } catch { + return undefined; + } + + if (!stat.isDirectory && !stat.isFile) { + return undefined; + } + + return getResourceAttachContext(editor.resource, stat.isDirectory); } private setOverlay(type: ChatDragAndDropType | undefined): void { @@ -217,20 +240,14 @@ export class ChatDragAndDrop extends Themable { } } -function getEditorAttachContext(editor: EditorInput | IDraggedResourceEditorInput): IChatRequestVariableEntry | undefined { - if (!editor.resource) { - return undefined; - } - return getFileAttachContext(editor.resource); -} - -function getFileAttachContext(resource: URI): IChatRequestVariableEntry | undefined { +function getResourceAttachContext(resource: URI, isDirectory: boolean): IChatRequestVariableEntry | undefined { return { value: resource, id: resource.toString(), name: basename(resource), - isFile: true, + isFile: !isDirectory, + isDirectory, isDynamic: true }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 522173d0f48..73f41a5bc16 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -65,7 +65,7 @@ registerAction2(class AddFileToWorkingSet extends WorkingSetAction { icon: Codicon.plus, menu: [{ id: MenuId.ChatEditingWidgetModifiedFilesToolbar, - when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Transient), + when: ContextKeyExpr.or(ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Transient), ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Suggested)), order: 0, group: 'navigation' }], @@ -87,7 +87,7 @@ registerAction2(class RemoveFileFromWorkingSet extends WorkingSetAction { icon: Codicon.close, menu: [{ id: MenuId.ChatEditingWidgetModifiedFilesToolbar, - when: ContextKeyExpr.or(ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Attached), ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Transient)), + when: ContextKeyExpr.or(ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Attached), ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Suggested), ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Transient)), order: 0, group: 'navigation' }], diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts index 472a7f6cddd..4cbe0612419 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { compareBy, delta } from '../../../../../base/common/arrays.js'; -import { AsyncIterableSource, raceTimeout } from '../../../../../base/common/async.js'; +import { coalesce, compareBy, delta } from '../../../../../base/common/arrays.js'; +import { AsyncIterableSource } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { BugIndicatingError } from '../../../../../base/common/errors.js'; @@ -393,6 +393,10 @@ export class ChatEditingService extends Disposable implements IChatEditingServic return editors; } + hasRelatedFilesProviders(): boolean { + return this._chatRelatedFilesProviders.size > 0; + } + registerRelatedFilesProvider(handle: number, provider: IChatRelatedFilesProvider): IDisposable { this._chatRelatedFilesProviders.set(handle, provider); return toDisposable(() => { @@ -400,28 +404,34 @@ export class ChatEditingService extends Disposable implements IChatEditingServic }); } - async getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise { + async getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise<{ group: string; files: IChatRelatedFile[] }[] | undefined> { const currentSession = this._currentSessionObs.get(); if (!currentSession || chatSessionId !== currentSession.chatSessionId) { return undefined; } - const currentWorkingSet = [...currentSession.workingSet.keys()]; + const userAddedWorkingSetEntries: URI[] = []; + for (const entry of currentSession.workingSet) { + // Don't incorporate suggested files into the related files request + // but do consider transient entries like open editors + if (entry[1].state !== WorkingSetEntryState.Suggested) { + userAddedWorkingSetEntries.push(entry[0]); + } + } const providers = Array.from(this._chatRelatedFilesProviders.values()); const result = await Promise.all(providers.map(async provider => { try { - return await raceTimeout(provider.provideRelatedFiles({ prompt, files: currentWorkingSet }, token), 2000); + const relatedFiles = await provider.provideRelatedFiles({ prompt, files: userAddedWorkingSetEntries }, token); + if (relatedFiles?.length) { + return { group: provider.description, files: relatedFiles }; + } + return undefined; } catch (e) { return undefined; } })); - return result.reduce((acc, cur) => { - if (cur) { - acc.push(...cur); - } - return acc; - }, []); + return coalesce(result); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 2566490bd6a..88a524ba636 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -30,7 +30,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js'; import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; import { ChatAgentLocation, IChatAgentService } from '../../common/chatAgents.js'; -import { ChatEditingSessionState, ChatEditKind, IChatEditingSession, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { ChatEditingSessionChangeType, ChatEditingSessionState, ChatEditKind, IChatEditingSession, WorkingSetDisplayMetadata, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { IChatResponseModel } from '../../common/chatModel.js'; import { IChatWidgetService } from '../chat.js'; import { ChatEditingMultiDiffSourceResolver } from './chatEditingService.js'; @@ -59,14 +59,14 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private readonly _sequencer = new Sequencer(); - private _workingSet = new ResourceMap(); + private _workingSet = new ResourceMap(); get workingSet() { this._assertNotDisposed(); // Return here a reunion between the AI modified entries and the user built working set - const result = new ResourceMap(this._workingSet); + const result = new ResourceMap(this._workingSet); for (const entry of this._entriesObs.get()) { - result.set(entry.modifiedURI, entry.state.get()); + result.set(entry.modifiedURI, { state: entry.state.get() }); } return result; @@ -101,7 +101,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return linearHistory.slice(linearHistoryIndex).map(s => s.requestId).filter((r): r is string => !!r); }); - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = new Emitter(); get onDidChange() { this._assertNotDisposed(); return this._onDidChange.event; @@ -155,7 +155,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio entries.forEach(entry => { entry.state.read(reader); }); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); })); } @@ -164,7 +164,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio const existingTransientEntries = new ResourceSet(); for (const file of this._workingSet.keys()) { - if (this._workingSet.get(file) === WorkingSetEntryState.Transient) { + if (this._workingSet.get(file)?.state === WorkingSetEntryState.Transient) { existingTransientEntries.add(file); } } @@ -199,12 +199,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } for (const entry of activeEditors) { - this._workingSet.set(entry, WorkingSetEntryState.Transient); + this._workingSet.set(entry, { state: WorkingSetEntryState.Transient, description: localize('chatEditing.transient', "Open Editor") }); didChange = true; } if (didChange) { - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } } @@ -213,7 +213,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio if (requestId) { this._snapshots.set(requestId, snapshot); for (const workingSetItem of this._workingSet.keys()) { - this._workingSet.set(workingSetItem, WorkingSetEntryState.Sent); + this._workingSet.set(workingSetItem, { state: WorkingSetEntryState.Sent }); } const linearHistory = this._linearHistory.get(); const linearHistoryIndex = this._linearHistoryIndex.get(); @@ -229,7 +229,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } private _createSnapshot(requestId: string | undefined): IChatEditingSessionSnapshot { - const workingSet = new ResourceMap(); + const workingSet = new ResourceMap(); for (const [file, state] of this._workingSet) { workingSet.set(file, state); } @@ -329,7 +329,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio continue; } didRemoveUris = this._workingSet.delete(uri) || didRemoveUris; - if (state === WorkingSetEntryState.Transient) { + if (state.state === WorkingSetEntryState.Transient || state.state === WorkingSetEntryState.Suggested) { this._removedTransientEntries.add(uri); } } @@ -338,7 +338,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return; // noop } - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } private _assertNotDisposed(): void { @@ -361,7 +361,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.Other); } async reject(...uris: URI[]): Promise { @@ -378,7 +378,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } } - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.Other); } async show(): Promise { @@ -468,11 +468,14 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio this._sequencer.queue(() => this._resolve()); } - addFileToWorkingSet(resource: URI) { + addFileToWorkingSet(resource: URI, description?: string, proposedState?: WorkingSetEntryState.Suggested): void { const state = this._workingSet.get(resource); - if (state === undefined || state === WorkingSetEntryState.Transient) { - this._workingSet.set(resource, WorkingSetEntryState.Attached); - this._onDidChange.fire(); + if (!state && proposedState === WorkingSetEntryState.Suggested) { + this._workingSet.set(resource, { description, state: WorkingSetEntryState.Suggested }); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } else if (state === undefined || state.state === WorkingSetEntryState.Transient) { + this._workingSet.set(resource, { description, state: WorkingSetEntryState.Attached }); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); } } @@ -551,7 +554,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._state.set(ChatEditingSessionState.Idle, tx); }); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.Other); } private async _getOrCreateModifiedFileEntry(resource: URI, responseModel: IModifiedEntryTelemetryInfo): Promise { @@ -576,11 +579,11 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio this._entriesObs.set(newEntries, undefined); this._workingSet.delete(entry.modifiedURI); entry.dispose(); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); })); const entriesArr = [...this._entriesObs.get(), entry]; this._entriesObs.set(entriesArr, undefined); - this._onDidChange.fire(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); return entry; } @@ -614,6 +617,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio export interface IChatEditingSessionSnapshot { requestId: string | undefined; - workingSet: ResourceMap; + workingSet: ResourceMap; entries: ResourceMap; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts b/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts index 79fb39d5c5f..c5c62968a66 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorSaving.ts @@ -267,12 +267,11 @@ export class ChatEditingSaveAllAction extends Action2 { id: MenuId.ChatEditingWidgetToolbar, group: 'navigation', order: 2, - // Show the option to save without accepting if the user has autosave - // and also hasn't configured the setting to always save with generated changes + // Show the option to save without accepting if the user hasn't configured the setting to always save with generated changes when: ContextKeyExpr.and( applyingChatEditsFailedContextKey.negate(), ContextKeyExpr.or(hasUndecidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey.negate()), - ContextKeyExpr.notEquals('config.files.autoSave', 'off'), ContextKeyExpr.equals(`config.${ChatEditorSaving._config}`, false), + ContextKeyExpr.equals(`config.${ChatEditorSaving._config}`, false), ChatContextKeys.location.isEqualTo(ChatAgentLocation.EditingSession) ) } diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index a2bd5d7f328..826bb837a45 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -26,7 +26,6 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../ import { ResourceSet } from '../../../../base/common/map.js'; import { basename, dirname } from '../../../../base/common/path.js'; import { isMacintosh } from '../../../../base/common/platform.js'; -import type { Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; @@ -34,7 +33,7 @@ import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/c import { EditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { IDimension } from '../../../../editor/common/core/dimension.js'; import { IPosition } from '../../../../editor/common/core/position.js'; -import { Range } from '../../../../editor/common/core/range.js'; +import { IRange, Range } from '../../../../editor/common/core/range.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { IModelService } from '../../../../editor/common/services/model.js'; @@ -53,7 +52,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import type { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { FileKind, IFileService } from '../../../../platform/files/common/files.js'; import { registerAndCreateHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -64,14 +63,15 @@ import { WorkbenchList } from '../../../../platform/list/browser/listService.js' import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IOpenerService, type OpenInternalOptions } from '../../../../platform/opener/common/opener.js'; -import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { FolderThemeIcon, IThemeService } from '../../../../platform/theme/common/themeService.js'; import { fillEditorsDragData } from '../../../browser/dnd.js'; -import { ResourceLabels } from '../../../browser/labels.js'; +import { IFileLabelOptions, ResourceLabels } from '../../../browser/labels.js'; import { ResourceContextKey } from '../../../common/contextkeys.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; +import { revealInsideBarCommand } from '../../files/browser/fileActions.contribution.js'; import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, WorkingSetEntryState } from '../common/chatEditingService.js'; @@ -267,6 +267,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatEditingService private readonly chatEditingService: IChatEditingService, @IMenuService private readonly menuService: IMenuService, @ILanguageService private readonly languageService: ILanguageService, + @IThemeService private readonly themeService: IThemeService, ) { super(); @@ -722,6 +723,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; this._register(this._inputEditor.onDidChangeCursorPosition(e => onDidChangeCursorPosition())); onDidChangeCursorPosition(); + + this._register(this.themeService.onDidFileIconThemeChange(() => { + this.renderAttachedContext(); + })); } private async renderAttachedContext() { @@ -753,27 +758,32 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let ariaLabel: string | undefined; - const file = URI.isUri(attachment.value) ? attachment.value : attachment.value && typeof attachment.value === 'object' && 'uri' in attachment.value && URI.isUri(attachment.value.uri) ? attachment.value.uri : undefined; + const resource = URI.isUri(attachment.value) ? attachment.value : attachment.value && typeof attachment.value === 'object' && 'uri' in attachment.value && URI.isUri(attachment.value.uri) ? attachment.value.uri : undefined; const range = attachment.value && typeof attachment.value === 'object' && 'range' in attachment.value && Range.isIRange(attachment.value.range) ? attachment.value.range : undefined; - if (file && attachment.isFile) { - const fileBasename = basename(file.path); - const fileDirname = dirname(file.path); + if (resource && (attachment.isFile || attachment.isDirectory)) { + const fileBasename = basename(resource.path); + const fileDirname = dirname(resource.path); const friendlyName = `${fileBasename} ${fileDirname}`; ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached file, {0}, line {1} to line {2}", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached file, {0}", friendlyName); - label.setFile(file, { + const fileOptions: IFileLabelOptions = { hidePath: true }; + label.setFile(resource, attachment.isFile ? { + ...fileOptions, fileKind: FileKind.FILE, - hidePath: true, range, + } : { + ...fileOptions, + fileKind: FileKind.FOLDER, + icon: !this.themeService.getFileIconTheme().hasFolderIcons ? FolderThemeIcon : undefined }); const scopedContextKeyService = store.add(this.contextKeyService.createScoped(widget)); const resourceContextKey = store.add(new ResourceContextKey(scopedContextKeyService, this.fileService, this.languageService, this.modelService)); - resourceContextKey.set(file); + resourceContextKey.set(resource); this.attachButtonAndDisposables(widget, index, attachment, hoverDelegate, { - contextMenuArg: file, + contextMenuArg: resource, contextKeyService: scopedContextKeyService, contextMenuId: MenuId.ChatInputResourceAttachmentContext, }); @@ -781,7 +791,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Drag and drop widget.draggable = true; this._register(dom.addDisposableListener(widget, 'dragstart', e => { - this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [file], e)); + this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [resource], e)); e.dataTransfer?.setDragImage(widget, 0, 0); })); @@ -834,36 +844,26 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - if (file) { + if (resource) { widget.style.cursor = 'pointer'; store.add(dom.addDisposableListener(widget, dom.EventType.CLICK, (e: MouseEvent) => { dom.EventHelper.stop(e, true); - const options: Mutable = { - fromUserGesture: true - }; - if (range) { - const textEditorOptions: ITextEditorOptions = { - selection: range - }; - options.editorOptions = textEditorOptions; + if (attachment.isDirectory) { + this.openResource(resource, true); + } else { + this.openResource(resource, false, range); } - this.openerService.open(file, options); })); store.add(dom.addDisposableListener(widget, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { dom.EventHelper.stop(e, true); - const options: Mutable = { - fromUserGesture: true - }; - if (range) { - const textEditorOptions: ITextEditorOptions = { - selection: range - }; - options.editorOptions = textEditorOptions; + if (attachment.isDirectory) { + this.openResource(resource, true); + } else { + this.openResource(resource, false, range); } - this.openerService.open(file, options); } })); } @@ -877,6 +877,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + private openResource(resource: URI, isDirectory: true): void; + private openResource(resource: URI, isDirectory: false, range: IRange | undefined): void; + private openResource(resource: URI, isDirectory?: boolean, range?: IRange): void { + if (isDirectory) { + // Reveal Directory in explorer + this.commandService.executeCommand(revealInsideBarCommand.id, resource); + return; + } + + // Open file in editor + const openTextEditorOptions: ITextEditorOptions | undefined = range ? { selection: range } : undefined; + const options: OpenInternalOptions = { + fromUserGesture: true, + editorOptions: openTextEditorOptions, + }; + this.openerService.open(resource, options); + } + private attachButtonAndDisposables(widget: HTMLElement, index: number, attachment: IChatRequestVariableEntry, hoverDelegate: IHoverDelegate, contextMenuOpts?: { contextMenuId: MenuId; contextKeyService: IContextKeyService; contextMenuArg: unknown }) { const store = this.attachedContextDisposables.value; if (!store) { @@ -996,7 +1014,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!seenEntries.has(file)) { entries.unshift({ reference: file, - state: state, + state: state.state, + description: state.description, kind: 'reference', }); seenEntries.add(file); diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts index e6881751522..917561dfaee 100644 --- a/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { isPatternInWord } from '../../../../../base/common/filters.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; @@ -486,18 +487,23 @@ class BuiltinDynamicCompletions extends Disposable { private async addFileEntries(widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { - const makeFileCompletionItem = (resource: URI): CompletionItem => { + const makeFileCompletionItem = (resource: URI, description?: string): CompletionItem => { const basename = this.labelService.getUriBasenameLabel(resource); const text = `${chatVariableLeader}file:${basename}`; + const uriLabel = this.labelService.getUriLabel(resource, { relative: true }); + const labelDescription = description + ? localize('fileEntryDescription', '{0} ({1})', uriLabel, description) + : uriLabel; + const sortText = description ? 'z' : '{'; // after `z` return { - label: { label: basename, description: this.labelService.getUriLabel(resource, { relative: true }) }, + label: { label: basename, description: labelDescription }, filterText: `${chatVariableLeader}${basename}`, insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text, range: info, kind: CompletionItemKind.File, - sortText: '{', // after `z` + sortText, command: { id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { id: 'vscode.file', @@ -518,6 +524,20 @@ class BuiltinDynamicCompletions extends Disposable { const seen = new ResourceSet(); const len = result.suggestions.length; + // RELATED FILES + if (widget.location === ChatAgentLocation.EditingSession && widget.viewModel && this._chatEditingService.currentEditingSessionObs.get()?.chatSessionId === widget.viewModel?.sessionId) { + const relatedFiles = (await raceTimeout(this._chatEditingService.getRelatedFiles(widget.viewModel.sessionId, widget.getInput(), token), 1000)) ?? []; + for (const relatedFileGroup of relatedFiles) { + for (const relatedFile of relatedFileGroup.files) { + if (seen.has(relatedFile.uri)) { + continue; + } + seen.add(relatedFile.uri); + result.suggestions.push(makeFileCompletionItem(relatedFile.uri, relatedFile.description)); + } + } + } + // HISTORY // always take the last N items for (const item of this.historyService.getHistory()) { @@ -576,16 +596,6 @@ class BuiltinDynamicCompletions extends Disposable { } } - // RELATED FILES - if (widget.location === ChatAgentLocation.EditingSession && widget.viewModel && this._chatEditingService.currentEditingSessionObs.get()?.chatSessionId === widget.viewModel?.sessionId) { - for (const relatedFile of (await this._chatEditingService.getRelatedFiles(widget.viewModel.sessionId, widget.getInput(), token) ?? [])) { - if (seen.has(relatedFile.uri)) { - continue; - } - result.suggestions.push(makeFileCompletionItem(relatedFile.uri)); - } - } - // mark results as incomplete because further typing might yield // in more search results result.incomplete = true; diff --git a/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts b/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts new file mode 100644 index 00000000000..ec3a9981445 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/contrib/chatInputRelatedFilesContrib.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ChatEditingSessionChangeType, IChatEditingService, WorkingSetEntryState } from '../../common/chatEditingService.js'; +import { IChatWidgetService } from '../chat.js'; + +export class ChatRelatedFilesContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'chat.relatedFilesWorkingSet'; + + private readonly chatEditingSessionDisposables = new DisposableStore(); + private _currentRelatedFilesRetrievalOperation: Promise | undefined; + + constructor( + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService + ) { + super(); + + this._handleNewEditingSession(); + this._register(this.chatEditingService.onDidCreateEditingSession(() => { + this.chatEditingSessionDisposables.clear(); + this._handleNewEditingSession(); + })); + } + + private _updateRelatedFileSuggestions() { + if (this._currentRelatedFilesRetrievalOperation) { + return; + } + + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); + if (currentEditingSession) { + const workingSetEntries = currentEditingSession.entries.get(); + const sent = workingSetEntries.find(entry => entry.state.get() === WorkingSetEntryState.Sent || entry.state.get() === WorkingSetEntryState.Modified || entry.state.get() === WorkingSetEntryState.Accepted || entry.state.get() === WorkingSetEntryState.Rejected); + if (sent) { + // Do this only for the initial working set state + return; + } + + const widget = this.chatWidgetService.getWidgetBySessionId(currentEditingSession.chatSessionId); + if (!widget) { + return; + } + + this._currentRelatedFilesRetrievalOperation = this.chatEditingService.getRelatedFiles(currentEditingSession.chatSessionId, widget.getInput(), CancellationToken.None) + .then((files) => { + if (!files?.length) { + return; + } + + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); + if (!currentEditingSession || currentEditingSession.chatSessionId !== widget.viewModel?.sessionId) { + return; // Might have disposed while we were calculating + } + + // Pick up to 2 related files, or however many we can still fit in the working set + const maximumRelatedFiles = Math.min(2, this.chatEditingService.editingSessionFileLimit - widget.input.chatEditWorkingSetFiles.length); + const newSuggestions = new ResourceSet(); + for (const group of files) { + for (const file of group.files) { + if (newSuggestions.size >= maximumRelatedFiles) { + break; + } + newSuggestions.add(file.uri); + } + } + + // Remove the existing related file suggestions from the working set + const existingSuggestedEntriesToRemove: URI[] = []; + for (const entry of currentEditingSession.workingSet) { + if (entry[1].state === WorkingSetEntryState.Suggested && !newSuggestions.has(entry[0])) { + existingSuggestedEntriesToRemove.push(entry[0]); + } + } + currentEditingSession?.remove(...existingSuggestedEntriesToRemove); + + // Add the new related file suggestions to the working set + for (const file of newSuggestions) { + currentEditingSession.addFileToWorkingSet(file, localize('relatedFile', "Suggested File"), WorkingSetEntryState.Suggested); + } + }) + .finally(() => { + this._currentRelatedFilesRetrievalOperation = undefined; + }); + } + } + + private _handleNewEditingSession() { + const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); + if (!currentEditingSession) { + return; + } + const widget = this.chatWidgetService.getWidgetBySessionId(currentEditingSession.chatSessionId); + if (!widget || widget.viewModel?.sessionId !== currentEditingSession.chatSessionId) { + return; + } + this.chatEditingSessionDisposables.add(currentEditingSession.onDidDispose(() => { + this.chatEditingSessionDisposables.clear(); + })); + this._updateRelatedFileSuggestions(); + const onDebouncedType = Event.debounce(widget.inputEditor.onDidChangeModelContent, () => null, 3000); + this.chatEditingSessionDisposables.add(onDebouncedType(() => { + this._updateRelatedFileSuggestions(); + })); + this.chatEditingSessionDisposables.add(currentEditingSession.onDidChange((e) => { + if (e === ChatEditingSessionChangeType.WorkingSet) { + this._updateRelatedFileSuggestions(); + } + })); + } + + override dispose() { + this.chatEditingSessionDisposables.dispose(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/media/chat.css b/src/vs/workbench/contrib/chat/browser/media/chat.css index 75cc1ad159b..120c42a6e81 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/media/chat.css @@ -882,6 +882,7 @@ have to be updated for changes to the rules above, or to support more deeply nes padding-right: 4px; padding-left: 2px; height: calc(100% + 4px); + outline-offset: -4px; } .interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button:hover { @@ -973,6 +974,10 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 0 2px 0 0; } +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label.predefined-file-icon::before { + padding: 0 0 0 2px; +} + .interactive-session .interactive-item-container.interactive-request .chat-attached-context .chat-attached-context-attachment { padding-right: 6px; } diff --git a/src/vs/workbench/contrib/chat/common/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/chatEditingService.ts index 36b074b0e8c..4cdc8f3fb38 100644 --- a/src/vs/workbench/contrib/chat/common/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEditingService.ts @@ -42,8 +42,9 @@ export interface IChatEditingService { getSnapshotUri(requestId: string, uri: URI): URI | undefined; restoreSnapshot(requestId: string | undefined): Promise; + hasRelatedFilesProviders(): boolean; registerRelatedFilesProvider(handle: number, provider: IChatRelatedFilesProvider): IDisposable; - getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise; + getRelatedFiles(chatSessionId: string, prompt: string, token: CancellationToken): Promise<{ group: string; files: IChatRelatedFile[] }[] | undefined>; } export interface IChatRequestDraft { @@ -51,25 +52,32 @@ export interface IChatRequestDraft { readonly files: readonly URI[]; } +export interface IChatRelatedFileProviderMetadata { + readonly description: string; +} + export interface IChatRelatedFile { readonly uri: URI; readonly description: string; } export interface IChatRelatedFilesProvider { + readonly description: string; provideRelatedFiles(chatRequest: IChatRequestDraft, token: CancellationToken): Promise; } +export interface WorkingSetDisplayMetadata { state: WorkingSetEntryState; description?: string } + export interface IChatEditingSession { readonly chatSessionId: string; - readonly onDidChange: Event; + readonly onDidChange: Event; readonly onDidDispose: Event; readonly state: IObservable; readonly entries: IObservable; readonly hiddenRequestIds: IObservable; - readonly workingSet: ResourceMap; + readonly workingSet: ResourceMap; readonly isVisible: boolean; - addFileToWorkingSet(uri: URI): void; + addFileToWorkingSet(uri: URI, description?: string, kind?: WorkingSetEntryState.Transient | WorkingSetEntryState.Suggested): void; show(): Promise; remove(...uris: URI[]): void; accept(...uris: URI[]): Promise; @@ -89,7 +97,13 @@ export const enum WorkingSetEntryState { Rejected, Transient, Attached, - Sent, + Sent, // TODO@joyceerhl remove this + Suggested, +} + +export const enum ChatEditingSessionChangeType { + WorkingSet, + Other, } export interface IModifiedFileEntry { diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 9bf78042f88..e87ec0d7fba 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -42,6 +42,7 @@ export interface IBaseChatRequestVariableEntry { */ isDynamic?: boolean; isFile?: boolean; + isDirectory?: boolean; isTool?: boolean; isImage?: boolean; } diff --git a/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts index 16744cbaff4..7dd9973b03b 100644 --- a/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts +++ b/src/vs/workbench/contrib/chat/common/codeBlockModelCollection.ts @@ -10,7 +10,6 @@ import { URI } from '../../../../base/common/uri.js'; import { Range } from '../../../../editor/common/core/range.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { EndOfLinePreference, ITextModel } from '../../../../editor/common/model.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { extractCodeblockUrisFromText, extractVulnerabilitiesFromText, IMarkdownVulnerability } from './annotations.js'; import { IChatRequestViewModel, IChatResponseViewModel, isResponseVM } from './chatViewModel.js'; @@ -28,18 +27,10 @@ interface CodeBlockEntry { readonly codemapperUri?: URI; } -type CodeBlockTextModel = { - readonly type: 'incomplete'; - readonly value: ITextModel; -} | { - readonly type: 'complete'; - readonly value: Promise>; -}; - export class CodeBlockModelCollection extends Disposable { private readonly _models = new Map>; vulns: readonly IMarkdownVulnerability[]; codemapperUri?: URI; }>(); @@ -53,7 +44,6 @@ export class CodeBlockModelCollection extends Disposable { constructor( @ILanguageService private readonly languageService: ILanguageService, - @IModelService private readonly modelService: IModelService, @ITextModelService private readonly textModelService: ITextModelService, ) { super(); @@ -70,7 +60,7 @@ export class CodeBlockModelCollection extends Disposable { return; } return { - model: entry.model.type === 'incomplete' ? Promise.resolve(entry.model.value) : entry.model.value.then(ref => ref.object.textEditorModel), + model: entry.model.then(ref => ref.object.textEditorModel), vulns: entry.vulns, codemapperUri: entry.codemapperUri }; @@ -82,10 +72,10 @@ export class CodeBlockModelCollection extends Disposable { return existing; } - const uri = this.getIncompleteModelUri(sessionId, chat, codeBlockIndex); - const model = this.modelService.createModel('', null, uri, true); + const uri = this.getCodeBlockUri(sessionId, chat, codeBlockIndex); + const model = this.textModelService.createModelReference(uri); this._models.set(this.getKey(sessionId, chat, codeBlockIndex), { - model: { type: 'incomplete', value: model }, + model: model, vulns: [], codemapperUri: undefined, }); @@ -98,7 +88,7 @@ export class CodeBlockModelCollection extends Disposable { this.delete(first); } - return { model: Promise.resolve(model), vulns: [], codemapperUri: undefined }; + return { model: model.then(x => x.object.textEditorModel), vulns: [], codemapperUri: undefined }; } private delete(key: string) { @@ -107,21 +97,13 @@ export class CodeBlockModelCollection extends Disposable { return; } - this.disposeModel(entry.model); + entry.model.then(ref => ref.object.dispose()); this._models.delete(key); } - private disposeModel(model: CodeBlockTextModel) { - if (model.type === 'complete') { - model.value.then(ref => ref.dispose()); - } else { - this.modelService.destroyModel(model.value.uri); - } - } - clear(): void { - this._models.forEach(async entry => this.disposeModel(entry.model)); + this._models.forEach(async entry => (await entry.model).dispose()); this._models.clear(); } @@ -146,15 +128,10 @@ export class CodeBlockModelCollection extends Disposable { markCodeBlockCompleted(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number): void { const entry = this._models.get(this.getKey(sessionId, chat, codeBlockIndex)); - if (!entry || entry.model.type === 'complete') { + if (!entry) { return; } - - this.disposeModel(entry.model); - - const uri = this.getCompletedModelUri(sessionId, chat, codeBlockIndex); - const newModel = this.textModelService.createModelReference(uri); - entry.model = { type: 'complete', value: newModel }; + // TODO: fill this in once we've implemented https://github.com/microsoft/vscode/issues/232538 } async update(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, content: CodeBlockContent): Promise { @@ -222,15 +199,7 @@ export class CodeBlockModelCollection extends Disposable { return `${sessionId}/${chat.id}/${index}`; } - private getIncompleteModelUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { - return URI.from({ - scheme: Schemas.inMemory, - authority: 'chat-code-block', - path: `/${sessionId}/${chat.id}/${index}` - }); - } - - private getCompletedModelUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { + private getCodeBlockUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { const metadata = this.getUriMetaData(chat); return URI.from({ scheme: Schemas.vscodeChatCodeBlock, diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 3addd18766f..4db7fda24f2 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -8,7 +8,7 @@ import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { MenuRegistry, MenuId, registerAction2, Action2, IMenuItem, IAction2Options } from '../../../../platform/actions/common/actions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApi } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { ExtensionsLocalizedLabel, IExtensionManagementService, IExtensionGalleryService, PreferencesLocalizedLabel, EXTENSION_INSTALL_SOURCE_CONTEXT, ExtensionInstallSource, UseUnpkgResourceApi, InstallOperation } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { EnablementState, IExtensionManagementServerService, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService, extensionsConfigurationNodeBase } from '../../../services/extensionManagement/common/extensionManagement.js'; import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js'; @@ -77,6 +77,7 @@ import { CONTEXT_KEYBINDINGS_EDITOR } from '../../preferences/common/preferences import { ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IConfigurationMigrationRegistry, Extensions as ConfigurationMigrationExtensions } from '../../../common/configuration.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; // Singletons registerSingleton(IExtensionsWorkbenchService, ExtensionsWorkbenchService, InstantiationType.Eager /* Auto updates extensions */); @@ -491,7 +492,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi constructor( @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService, - @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IContextKeyService contextKeyService: IContextKeyService, @IViewsService private readonly viewsService: IViewsService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @@ -499,6 +500,10 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi @IInstantiationService private readonly instantiationService: IInstantiationService, @IDialogService private readonly dialogService: IDialogService, @ICommandService private readonly commandService: ICommandService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IProductService private readonly productService: IProductService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @INotificationService private readonly notificationService: INotificationService, ) { super(); const hasGalleryContext = CONTEXT_HAS_GALLERY.bindTo(contextKeyService); @@ -1582,6 +1587,53 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi run: async (accessor: ServicesAccessor, id: string) => accessor.get(IPreferencesService).openSettings({ jsonEditor: false, query: `@ext:${id}` }) }); + const downloadVSIX = async (extensionId: string, preRelease: boolean) => { + const result = await this.fileDialogService.showOpenDialog({ + title: localize('download title', "Select folder to download the VSIX"), + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: localize('download', "Download"), + }); + + if (!result?.[0]) { + return; + } + + const [galleryExtension] = await this.extensionGalleryService.getExtensions([{ id: extensionId, preRelease: true }], { compatible: true }, CancellationToken.None); + if (!galleryExtension) { + throw new Error(localize('not found', "Extension '{0}' not found.", extensionId)); + } + await this.extensionGalleryService.download(galleryExtension, this.uriIdentityService.extUri.joinPath(result[0], `${galleryExtension.identifier.id}-${galleryExtension.version}.vsix`), InstallOperation.None); + this.notificationService.info(localize('download.completed', "Successfully downloaded the VSIX")); + }; + + this.registerExtensionAction({ + id: 'workbench.extensions.action.download', + title: localize('download VSIX', "Download VSIX"), + menu: { + id: MenuId.ExtensionContext, + when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension')), + order: this.productService.quality === 'stable' ? 0 : 1 + }, + run: async (accessor: ServicesAccessor, extensionId: string) => { + downloadVSIX(extensionId, false); + } + }); + + this.registerExtensionAction({ + id: 'workbench.extensions.action.downloadPreRelease', + title: localize('download pre-release', "Download Pre-Release VSIX"), + menu: { + id: MenuId.ExtensionContext, + when: ContextKeyExpr.and(ContextKeyExpr.equals('extensionStatus', 'uninstalled'), ContextKeyExpr.has('isGalleryExtension'), ContextKeyExpr.has('extensionHasPreReleaseVersion')), + order: this.productService.quality === 'stable' ? 1 : 0 + }, + run: async (accessor: ServicesAccessor, extensionId: string) => { + downloadVSIX(extensionId, true); + } + }); + this.registerExtensionAction({ id: 'workbench.extensions.action.manageAccountPreferences', title: localize2('workbench.extensions.action.changeAccountPreference', "Account Preferences"), diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index e353589e400..bcad49acc19 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -328,9 +328,7 @@ export class ExplorerFindProvider implements IAsyncFindProvider { this.startHighlightSession(); } - await this.doHighlightFind(pattern, toggles.matchType, token); - - return {}; + return await this.doHighlightFind(pattern, toggles.matchType, token); } if (this.highlightSessionStartState) { @@ -475,7 +473,7 @@ export class ExplorerFindProvider implements IAsyncFindProvider { this.highlightSessionStartState = { rootsWithProviders: new Set(roots) }; } - async doHighlightFind(pattern: string, matchType: TreeFindMatchType, token: CancellationToken): Promise { + async doHighlightFind(pattern: string, matchType: TreeFindMatchType, token: CancellationToken): Promise { if (!this.highlightSessionStartState) { throw new Error('ExplorerFindProvider: no highlight session state'); } @@ -484,13 +482,16 @@ export class ExplorerFindProvider implements IAsyncFindProvider { const searchResults = await this.getSearchResults(pattern, roots, matchType, token); if (token.isCancellationRequested) { - return; + return {}; } this.clearHighlights(); for (const { explorerRoot, files, directories } of searchResults) { this.addWorkspaceHighlightResults(explorerRoot, files.concat(directories)); } + + const hitMaxResults = searchResults.some(({ hitMaxResults }) => hitMaxResults); + return { warningMessage: hitMaxResults ? localize('searchMaxResultsWarning', "The result set only contains a subset of all matches. Be more specific in your search to narrow down the results.") : undefined }; } private addWorkspaceHighlightResults(root: ExplorerItem, resources: URI[]): void { @@ -565,14 +566,13 @@ export class ExplorerFindProvider implements IAsyncFindProvider { shouldGlobMatchFilePattern: true, cacheKey: `explorerfindprovider:${root.name}:${rootIndex}:${this.sessionId}`, excludePattern: searchExcludePattern, - maxResults: 512 }; let fileResults: ISearchComplete | undefined; let folderResults: ISearchComplete | undefined; try { [fileResults, folderResults] = await Promise.all([ - this.searchService.fileSearch({ ...searchOptions, filePattern: `**/${segmentMatchPattern}` }, token), + this.searchService.fileSearch({ ...searchOptions, filePattern: `**/${segmentMatchPattern}`, maxResults: 512 }, token), this.searchService.fileSearch({ ...searchOptions, filePattern: `**/${segmentMatchPattern}/**` }, token) ]); } catch (e) { @@ -908,8 +908,8 @@ export class FilesRenderer implements ICompressibleTreeRenderer 2) { + let fuzzyScore = node.filterData as FuzzyScore | undefined; + if (fuzzyScore && fuzzyScore.length > 2) { const filterDataOffset = labels.join('/').length - labels[labels.length - 1].length; fuzzyScore = [fuzzyScore[0], fuzzyScore[1] + filterDataOffset, ...fuzzyScore.slice(2)]; } diff --git a/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts b/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts index ff09fc5bc7b..249ee28a2f5 100644 --- a/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts +++ b/src/vs/workbench/contrib/interactive/browser/interactive.contribution.ts @@ -752,13 +752,8 @@ registerAction2(class extends Action2 { category: interactiveWindowCategory, menu: { id: MenuId.CommandPalette, - when: InteractiveWindowOpen, + when: InteractiveWindowOpen }, - keybinding: { - when: ContextKeyExpr.and(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_EDITOR_FOCUSED), - weight: KeybindingWeight.WorkbenchContrib + 5, - primary: KeyMod.CtrlCmd | KeyCode.DownArrow - } }); } diff --git a/src/vs/workbench/contrib/issue/browser/issueFormService.ts b/src/vs/workbench/contrib/issue/browser/issueFormService.ts index f2e9cad950f..c2449a6a102 100644 --- a/src/vs/workbench/contrib/issue/browser/issueFormService.ts +++ b/src/vs/workbench/contrib/issue/browser/issueFormService.ts @@ -111,8 +111,8 @@ export class IssueFormService implements IIssueFormService { const actions = menu.getActions({ renderShortTitle: true }).flatMap(entry => entry[1]); for (const action of actions) { try { - if (action.item && 'source' in action.item && action.item.source?.id === extensionId) { - this.extensionIdentifierSet.add(extensionId); + if (action.item && 'source' in action.item && action.item.source?.id.toLowerCase() === extensionId.toLowerCase()) { + this.extensionIdentifierSet.add(extensionId.toLowerCase()); await action.run(); } } catch (error) { diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookCellDecorators.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookCellDecorators.ts new file mode 100644 index 00000000000..2c1f61ab35f --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookCellDecorators.ts @@ -0,0 +1,449 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { Disposable, DisposableStore, dispose, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived } from '../../../../../base/common/observable.js'; +import { IChatEditingService, ChatEditingSessionState } from '../../../chat/common/chatEditingService.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { INotebookEditor } from '../notebookBrowser.js'; +import { ThrottledDelayer } from '../../../../../base/common/async.js'; +import { CellDiffInfo } from '../diff/notebookDiffViewModel.js'; +import { CellKind } from '../../common/notebookCommon.js'; +import { ICodeEditor, IViewZone } from '../../../../../editor/browser/editorBrowser.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; +import { themeColorFromId } from '../../../../../base/common/themables.js'; +import { RenderOptions, LineSource, renderLines } from '../../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; +import { diffAddDecoration, diffWholeLineAddDecoration, diffDeleteDecoration } from '../../../../../editor/browser/widget/diffEditor/registrations.contribution.js'; +import { IDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; +import { ITextModel, TrackedRangeStickiness, MinimapPosition, IModelDeltaDecoration, OverviewRulerLane } from '../../../../../editor/common/model.js'; +import { ModelDecorationOptions } from '../../../../../editor/common/model/textModel.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../../editor/common/viewModel.js'; +import { overviewRulerModifiedForeground, minimapGutterModifiedBackground, overviewRulerAddedForeground, minimapGutterAddedBackground, overviewRulerDeletedForeground, minimapGutterDeletedBackground } from '../../../scm/browser/dirtydiffDecorator.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { NotebookCellTextModel } from '../../common/model/notebookCellTextModel.js'; +import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { createTrustedTypesPolicy } from '../../../../../base/browser/trustedTypes.js'; +import { splitLines } from '../../../../../base/common/strings.js'; +import { DefaultLineHeight } from '../diff/diffElementViewModel.js'; +import { INotebookOriginalCellModelFactory } from './notebookOriginalCellModelFactory.js'; + + +export class NotebookCellDiffDecorator extends DisposableStore { + private readonly _decorations = this.editor.createDecorationsCollection(); + private _viewZones: string[] = []; + private readonly throttledDecorator = new ThrottledDelayer(100); + + constructor( + public readonly editor: ICodeEditor, + private readonly originalCellValue: string, + private readonly cellKind: CellKind, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @INotebookOriginalCellModelFactory private readonly originalCellModelFactory: INotebookOriginalCellModelFactory, + + ) { + super(); + this.add(this.editor.onDidChangeModel(() => this.update())); + this.add(this.editor.onDidChangeConfiguration((e) => { + if (e.hasChanged(EditorOption.fontInfo) || e.hasChanged(EditorOption.lineHeight)) { + this.update(); + } + })); + + const shouldBeReadOnly = derived(this, r => { + const value = this._chatEditingService.currentEditingSessionObs.read(r); + if (!value || value.state.read(r) !== ChatEditingSessionState.StreamingEdits) { + return false; + } + return value.entries.read(r).some(e => isEqual(e.modifiedURI, this.editor.getModel()?.uri)); + }); + + + let actualReadonly: boolean | undefined; + let actualDeco: 'off' | 'editable' | 'on' | undefined; + + this.add(autorun(r => { + const value = shouldBeReadOnly.read(r); + if (value) { + actualReadonly ??= this.editor.getOption(EditorOption.readOnly); + actualDeco ??= this.editor.getOption(EditorOption.renderValidationDecorations); + + this.editor.updateOptions({ + readOnly: true, + renderValidationDecorations: 'off' + }); + } else { + if (actualReadonly !== undefined && actualDeco !== undefined) { + this.editor.updateOptions({ + readOnly: actualReadonly, + renderValidationDecorations: actualDeco + }); + actualReadonly = undefined; + actualDeco = undefined; + } + } + })); + this.update(); + } + + override dispose(): void { + this._clearRendering(); + super.dispose(); + } + + public update(): void { + this.throttledDecorator.trigger(() => this._updateImpl()); + } + + private async _updateImpl() { + if (this.isDisposed) { + return; + } + if (!this.editor.hasModel()) { + this._clearRendering(); + return; + } + if (this.editor.getOption(EditorOption.inDiffEditor)) { + this._clearRendering(); + return; + } + const model = this.editor.getModel(); + if (!model) { + this._clearRendering(); + return; + } + + const version = model.getVersionId(); + const originalModel = this.getOrCreateOriginalModel(); + const diff = originalModel ? await this.computeDiff() : undefined; + if (this.isDisposed) { + return; + } + + if (diff && originalModel && model === this.editor.getModel() && this.editor.getModel()?.getVersionId() === version) { + this._updateWithDiff(originalModel, diff); + } else { + this._clearRendering(); + } + } + + private _clearRendering() { + this.editor.changeViewZones((viewZoneChangeAccessor) => { + for (const id of this._viewZones) { + viewZoneChangeAccessor.removeZone(id); + } + }); + this._viewZones = []; + this._decorations.clear(); + } + + private _originalModel?: ITextModel; + private getOrCreateOriginalModel() { + if (!this._originalModel) { + const model = this.editor.getModel(); + if (!model) { + return; + } + this._originalModel = this.add(this.originalCellModelFactory.getOrCreate(model.uri, this.originalCellValue, model.getLanguageId(), this.cellKind)).object; + } + return this._originalModel; + } + private async computeDiff() { + const model = this.editor.getModel(); + if (!model) { + return; + } + const originalModel = this.getOrCreateOriginalModel(); + if (!originalModel) { + return; + } + + return this._editorWorkerService.computeDiff( + originalModel.uri, + model.uri, + { computeMoves: true, ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER }, + 'advanced' + ); + } + + private _updateWithDiff(originalModel: ITextModel | undefined, diff: IDocumentDiff): void { + const chatDiffAddDecoration = ModelDecorationOptions.createDynamic({ + ...diffAddDecoration, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }); + const chatDiffWholeLineAddDecoration = ModelDecorationOptions.createDynamic({ + ...diffWholeLineAddDecoration, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }); + const createOverviewDecoration = (overviewRulerColor: string, minimapColor: string) => { + return ModelDecorationOptions.createDynamic({ + description: 'chat-editing-decoration', + overviewRuler: { color: themeColorFromId(overviewRulerColor), position: OverviewRulerLane.Left }, + minimap: { color: themeColorFromId(minimapColor), position: MinimapPosition.Gutter }, + }); + }; + const modifiedDecoration = createOverviewDecoration(overviewRulerModifiedForeground, minimapGutterModifiedBackground); + const addedDecoration = createOverviewDecoration(overviewRulerAddedForeground, minimapGutterAddedBackground); + const deletedDecoration = createOverviewDecoration(overviewRulerDeletedForeground, minimapGutterDeletedBackground); + + this.editor.changeViewZones((viewZoneChangeAccessor) => { + for (const id of this._viewZones) { + viewZoneChangeAccessor.removeZone(id); + } + this._viewZones = []; + const modifiedDecorations: IModelDeltaDecoration[] = []; + const mightContainNonBasicASCII = originalModel?.mightContainNonBasicASCII(); + const mightContainRTL = originalModel?.mightContainRTL(); + const renderOptions = RenderOptions.fromEditor(this.editor); + + for (const diffEntry of diff.changes) { + const originalRange = diffEntry.original; + if (originalModel) { + originalModel.tokenization.forceTokenization(Math.max(1, originalRange.endLineNumberExclusive - 1)); + } + const source = new LineSource( + (originalRange.length && originalModel) ? originalRange.mapToLineArray(l => originalModel.tokenization.getLineTokens(l)) : [], + [], + mightContainNonBasicASCII, + mightContainRTL, + ); + const decorations: InlineDecoration[] = []; + for (const i of diffEntry.innerChanges || []) { + decorations.push(new InlineDecoration( + i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)), + diffDeleteDecoration.className!, + InlineDecorationType.Regular + )); + modifiedDecorations.push({ + range: i.modifiedRange, options: chatDiffAddDecoration + }); + } + if (!diffEntry.modified.isEmpty) { + modifiedDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, options: chatDiffWholeLineAddDecoration + }); + } + + if (diffEntry.original.isEmpty) { + // insertion + modifiedDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, + options: addedDecoration + }); + } else if (diffEntry.modified.isEmpty) { + // deletion + modifiedDecorations.push({ + range: new Range(diffEntry.modified.startLineNumber - 1, 1, diffEntry.modified.startLineNumber, 1), + options: deletedDecoration + }); + } else { + // modification + modifiedDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, + options: modifiedDecoration + }); + } + const domNode = document.createElement('div'); + domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text'; + const result = renderLines(source, renderOptions, decorations, domNode); + + const isCreatedContent = decorations.length === 1 && decorations[0].range.isEmpty() && decorations[0].range.startLineNumber === 1; + if (!isCreatedContent) { + const viewZoneData: IViewZone = { + afterLineNumber: diffEntry.modified.startLineNumber - 1, + heightInLines: result.heightInLines, + domNode, + ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42 + }; + + this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData)); + } + } + + this._decorations.set(modifiedDecorations); + }); + } +} + +export class NotebookInsertedCellDecorator extends Disposable { + private readonly decorators = this._register(new DisposableStore()); + constructor( + private readonly notebookEditor: INotebookEditor, + ) { + super(); + + } + public apply(diffInfo: CellDiffInfo[]) { + const model = this.notebookEditor.textModel; + if (!model) { + return; + } + const cells = diffInfo.filter(diff => diff.type === 'insert').map((diff) => model.cells[diff.modifiedCellIndex]); + const ids = this.notebookEditor.deltaCellDecorations([], cells.map(cell => ({ + handle: cell.handle, + options: { className: 'nb-insertHighlight', outputClassName: 'nb-insertHighlight' } + }))); + this.clear(); + this.decorators.add(toDisposable(() => { + if (!this.notebookEditor.isDisposed) { + this.notebookEditor.deltaCellDecorations(ids, []); + } + })); + } + public clear() { + this.decorators.clear(); + } +} + +const ttPolicy = createTrustedTypesPolicy('notebookChatEditController', { createHTML: value => value }); + +export class NotebookDeletedCellDecorator extends Disposable { + private readonly zoneRemover = this._register(new DisposableStore()); + private readonly createdViewZones = new Map(); + constructor( + private readonly _notebookEditor: INotebookEditor, + @ILanguageService private readonly languageService: ILanguageService, + ) { + super(); + } + + + public apply(diffInfo: CellDiffInfo[], original: NotebookTextModel): void { + this.clear(); + + let currentIndex = 0; + const deletedCellsToRender: { cells: NotebookCellTextModel[]; index: number } = { cells: [], index: 0 }; + diffInfo.forEach(diff => { + if (diff.type === 'delete') { + const deletedCell = original.cells[diff.originalCellIndex]; + if (deletedCell) { + deletedCellsToRender.cells.push(deletedCell); + deletedCellsToRender.index = currentIndex; + } + } else { + if (deletedCellsToRender.cells.length) { + this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); + deletedCellsToRender.cells.length = 0; + } + currentIndex = diff.modifiedCellIndex; + } + }); + if (deletedCellsToRender.cells.length) { + this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); + } + } + + public clear() { + this.zoneRemover.clear(); + } + + + private _createWidget(index: number, cells: NotebookCellTextModel[]) { + this._createWidgetImpl(index, cells); + } + private async _createWidgetImpl(index: number, cells: NotebookCellTextModel[]) { + const rootContainer = document.createElement('div'); + const widgets = cells.map(cell => new NotebookDeletedCellWidget(this._notebookEditor, cell.getValue(), cell.language, rootContainer, this.languageService)); + const heights = await Promise.all(widgets.map(w => w.render())); + const totalHeight = heights.reduce((prev, curr) => prev + curr, 0); + + this._notebookEditor.changeViewZones(accessor => { + const notebookViewZone = { + afterModelPosition: index, + heightInPx: totalHeight + 4, + domNode: rootContainer + }; + + const id = accessor.addZone(notebookViewZone); + accessor.layoutZone(id); + this.createdViewZones.set(index, id); + this.zoneRemover.add(toDisposable(() => { + if (this.createdViewZones.get(index) === id) { + this.createdViewZones.delete(index); + } + if (!this._notebookEditor.isDisposed) { + this._notebookEditor.changeViewZones(accessor => { + accessor.removeZone(id); + dispose(widgets); + }); + } + })); + }); + } + +} + +export class NotebookDeletedCellWidget extends Disposable { + private readonly container: HTMLElement; + constructor( + private readonly _notebookEditor: INotebookEditor, + // private readonly _index: number, + private readonly code: string, + private readonly language: string, + container: HTMLElement, + @ILanguageService private readonly languageService: ILanguageService, + ) { + super(); + this.container = DOM.append(container, document.createElement('div')); + this._register(toDisposable(() => { + container.removeChild(this.container); + })); + } + + public async render() { + const code = this.code; + const languageId = this.language; + const codeHtml = await tokenizeToString(this.languageService, code, languageId); + + // const colorMap = this.getDefaultColorMap(); + const fontInfo = this._notebookEditor.getBaseCellEditorOptions(languageId).value; + const fontFamilyVar = '--notebook-editor-font-family'; + const fontSizeVar = '--notebook-editor-font-size'; + const fontWeightVar = '--notebook-editor-font-weight'; + // If we have any editors, then use left layout of one of those. + const editor = this._notebookEditor.codeEditors.map(c => c[1]).find(c => c); + const layoutInfo = editor?.getOptions().get(EditorOption.layoutInfo); + + const style = `` + + `font-family: var(${fontFamilyVar});` + + `font-weight: var(${fontWeightVar});` + + `font-size: var(${fontSizeVar});` + + fontInfo.lineHeight ? `line-height: ${fontInfo.lineHeight}px;` : '' + + layoutInfo?.contentLeft ? `margin-left: ${layoutInfo}px;` : '' + + `white-space: pre;`; + + + + const rootContainer = this.container; + rootContainer.classList.add('code-cell-row'); + const container = DOM.append(rootContainer, DOM.$('.cell-inner-container')); + const focusIndicatorLeft = DOM.append(container, DOM.$('.cell-focus-indicator.cell-focus-indicator-side.cell-focus-indicator-left')); + const cellContainer = DOM.append(container, DOM.$('.cell.code')); + DOM.append(focusIndicatorLeft, DOM.$('div.execution-count-label')); + const editorPart = DOM.append(cellContainer, DOM.$('.cell-editor-part')); + let editorContainer = DOM.append(editorPart, DOM.$('.cell-editor-container')); + editorContainer = DOM.append(editorContainer, DOM.$('.code', { style })); + if (fontInfo.fontFamily) { + editorContainer.style.setProperty(fontFamilyVar, fontInfo.fontFamily); + } + if (fontInfo.fontSize) { + editorContainer.style.setProperty(fontSizeVar, `${fontInfo.fontSize}px`); + } + if (fontInfo.fontWeight) { + editorContainer.style.setProperty(fontWeightVar, fontInfo.fontWeight); + } + editorContainer.innerHTML = (ttPolicy?.createHTML(codeHtml) || codeHtml) as string; + + const lineCount = splitLines(code).length; + const height = (lineCount * (fontInfo.lineHeight || DefaultLineHeight)) + 12 + 12; // We have 12px top and bottom in generated code HTML; + const totalHeight = height + 16 + 16; + + return totalHeight; + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatActionsOverlay.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatActionsOverlay.ts new file mode 100644 index 00000000000..fc618d9aeb1 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatActionsOverlay.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { INotebookEditor } from '../notebookBrowser.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { ActionRunner, IAction, IActionRunner } from '../../../../../base/common/actions.js'; +import { $ } from '../../../../../base/browser/dom.js'; +import { IChatEditingService, IModifiedFileEntry } from '../../../chat/common/chatEditingService.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { autorunWithStore, observableFromEvent } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; + +export class NotebookChatActionsOverlayController extends Disposable { + constructor( + private readonly notebookEditor: INotebookEditor, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const notebookModel = observableFromEvent(this.notebookEditor.onDidChangeModel, e => e); + + this._register(autorunWithStore((r, store) => { + const session = this._chatEditingService.currentEditingSessionObs.read(r); + const model = notebookModel.read(r); + if (!model || !session) { + return; + } + + const entries = session.entries.read(r); + const idx = entries.findIndex(e => isEqual(e.modifiedURI, model.uri)); + if (idx >= 0) { + const entry = entries[idx]; + const nextEntry = entries[(idx + 1) % entries.length]; + const previousEntry = entries[(idx - 1 + entries.length) % entries.length]; + store.add(instantiationService.createInstance(NotebookChatActionsOverlay, notebookEditor, entry, nextEntry, previousEntry)); + } + })); + } +} + +// Copied from src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts (until we unify these) +export class NotebookChatActionsOverlay extends Disposable { + constructor( + notebookEditor: INotebookEditor, + entry: IModifiedFileEntry, + nextEntry: IModifiedFileEntry, + previousEntry: IModifiedFileEntry, + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService instaService: IInstantiationService, + ) { + super(); + const toolbarNode = $('div'); + toolbarNode.classList.add('notebook-chat-editor-overlay-widget'); + notebookEditor.getDomNode().appendChild(toolbarNode); + + this._register(toDisposable(() => { + notebookEditor.getDomNode().removeChild(toolbarNode); + })); + + const _toolbar = instaService.createInstance(MenuWorkbenchToolBar, toolbarNode, MenuId.ChatEditingEditorContent, { + telemetrySource: 'chatEditor.overlayToolbar', + hiddenItemStrategy: HiddenItemStrategy.Ignore, + toolbarOptions: { + primaryGroup: () => true, + useSeparatorsInPrimaryActions: true + }, + menuOptions: { renderShortTitle: true }, + actionViewItemProvider: (action, options) => { + const that = this; + + if (action.id === 'chatEditor.action.accept' || action.id === 'chatEditor.action.reject') { + return new class extends ActionViewItem { + private readonly _reveal = this._store.add(new MutableDisposable()); + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + override set actionRunner(actionRunner: IActionRunner) { + super.actionRunner = actionRunner; + + const store = new DisposableStore(); + + store.add(actionRunner.onWillRun(_e => { + notebookEditor.focus(); + })); + store.add(actionRunner.onDidRun(e => { + if (e.action !== this.action) { + return; + } + if (entry === nextEntry) { + return; + } + const change = nextEntry.diffInfo.get().changes.at(0); + return that._editorService.openEditor({ + resource: nextEntry.modifiedURI, + options: { + selection: change && Range.fromPositions({ lineNumber: change.original.startLineNumber, column: 1 }), + revealIfOpened: false, + revealIfVisible: false, + } + }, ACTIVE_GROUP); + })); + + this._reveal.value = store; + } + override get actionRunner(): IActionRunner { + return super.actionRunner; + } + }; + } + // Override next/previous with our implementation. + if (action.id === 'chatEditor.action.navigateNext' || action.id === 'chatEditor.action.navigatePrevious') { + return new class extends ActionViewItem { + constructor() { + super(undefined, action, { ...options, icon: true, label: false, keybindingNotRenderedWithLabel: true }); + } + override set actionRunner(_: IActionRunner) { + const next = action.id === 'chatEditor.action.navigateNext' ? nextEntry : previousEntry; + super.actionRunner = new NextPreviousChangeActionRunner(entry, next, _editorService); + } + override get actionRunner(): IActionRunner { + return super.actionRunner; + } + }; + } + return undefined; + } + + }); + + this._register(_toolbar); + } + + +} + +class NextPreviousChangeActionRunner extends ActionRunner { + constructor(private readonly entry: IModifiedFileEntry, private readonly next: IModifiedFileEntry, private readonly _editorService: IEditorService) { + super(); + } + protected override async runAction(action: IAction, context?: unknown): Promise { + if (this.entry === this.next) { + return; + } + // For now just go to next/previous file. + const change = this.next.diffInfo.get().changes.at(0); + await this._editorService.openEditor({ + resource: this.next.modifiedURI, + options: { + selection: change && Range.fromPositions({ lineNumber: change.original.startLineNumber, column: 1 }), + revealIfOpened: false, + revealIfVisible: false, + } + }, ACTIVE_GROUP); + } + +} diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatEditController.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatEditController.ts new file mode 100644 index 00000000000..a5c4073de72 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookChatEditController.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { Disposable, dispose, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, derivedWithStore, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { IChatEditingService, WorkingSetEntryState } from '../../../chat/common/chatEditingService.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { INotebookEditor, INotebookEditorContribution } from '../notebookBrowser.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { NotebookCellTextModel } from '../../common/model/notebookCellTextModel.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { NotebookDeletedCellDecorator, NotebookInsertedCellDecorator, NotebookCellDiffDecorator } from './notebookCellDecorators.js'; +import { INotebookModelSynchronizerFactory } from './notebookSynronizer.js'; +import { INotebookOriginalModelReferenceFactory } from './notebookOriginalModelRefFactory.js'; +import { debouncedObservable2 } from '../../../../../base/common/observableInternal/utils.js'; +import { CellDiffInfo } from '../diff/notebookDiffViewModel.js'; +import { NotebookChatActionsOverlayController } from './notebookChatActionsOverlay.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { localize } from '../../../../../nls.js'; + +export const ctxNotebookHasEditorModification = new RawContextKey('chat.hasNotebookEditorModifications', undefined, localize('chat.hasNotebookEditorModifications', "The current Notebook editor contains chat modifications")); + +export class NotebookChatEditorControllerContrib extends Disposable implements INotebookEditorContribution { + + public static readonly ID: string = 'workbench.notebook.chatEditorController'; + readonly _serviceBrand: undefined; + constructor( + notebookEditor: INotebookEditor, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + + ) { + super(); + if (configurationService.getValue('notebook.experimental.chatEdits')) { + this._register(instantiationService.createInstance(NotebookChatEditorController, notebookEditor)); + } + } +} + + +class NotebookChatEditorController extends Disposable { + private readonly deletedCellDecorator: NotebookDeletedCellDecorator; + private readonly insertedCellDecorator: NotebookInsertedCellDecorator; + private readonly _ctxHasEditorModification: IContextKey; + constructor( + private readonly notebookEditor: INotebookEditor, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @INotebookOriginalModelReferenceFactory private readonly originalModelRefFactory: INotebookOriginalModelReferenceFactory, + @INotebookModelSynchronizerFactory private readonly synchronizerFactory: INotebookModelSynchronizerFactory, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + this._ctxHasEditorModification = ctxNotebookHasEditorModification.bindTo(contextKeyService); + this._register(instantiationService.createInstance(NotebookChatActionsOverlayController, notebookEditor)); + this.deletedCellDecorator = this._register(instantiationService.createInstance(NotebookDeletedCellDecorator, notebookEditor)); + this.insertedCellDecorator = this._register(instantiationService.createInstance(NotebookInsertedCellDecorator, notebookEditor)); + const notebookModel = observableFromEvent(this.notebookEditor.onDidChangeModel, e => e); + const originalModel = observableValue('originalModel', undefined); + const viewModelAttached = observableFromEvent(this.notebookEditor.onDidAttachViewModel, () => !!this.notebookEditor.getViewModel()); + const onDidChangeVisibleRanges = debouncedObservable2(observableFromEvent(this.notebookEditor.onDidChangeVisibleRanges, () => this.notebookEditor.visibleRanges), 100); + const decorators = new Map(); + + let updatedCellDecoratorsOnceBefore = false; + let updatedDeletedInsertedDecoratorsOnceBefore = false; + + + const clearDecorators = () => { + dispose(Array.from(decorators.values())); + decorators.clear(); + this.deletedCellDecorator.clear(); + this.insertedCellDecorator.clear(); + }; + + this._register(toDisposable(() => clearDecorators())); + + const entryObs = derived((r) => { + const session = this._chatEditingService.currentEditingSessionObs.read(r); + const model = notebookModel.read(r); + if (!model || !session) { + return; + } + const entry = session.entries.read(r).find(e => isEqual(e.modifiedURI, model.uri)); + + if (!entry || entry.state.read(r) !== WorkingSetEntryState.Modified) { + clearDecorators(); + return; + } + return entry; + }).recomputeInitiallyAndOnChange(this._store); + + + const snapshotCreated = observableValue('snapshotCreated', false); + const diffInfoObs = derivedWithStore(this, (r, store) => { + const entry = entryObs.read(r); + const model = notebookModel.read(r); + if (!entry || !model) { + return observableValue<{ + cellDiff: CellDiffInfo[]; + modelVersion: number; + } | undefined>('DefaultDiffIno', undefined); + } + const notebookSynchronizer = store.add(this.synchronizerFactory.getOrCreate(model, entry)); + + // Initialize the observables. + notebookSynchronizer.object.createSnapshot().finally(() => snapshotCreated.set(true, undefined)); + this.originalModelRefFactory.getOrCreate(entry, model.viewType).then(ref => originalModel.set(this._register(ref).object, undefined)); + + return notebookSynchronizer.object.diffInfo; + }).recomputeInitiallyAndOnChange(this._store).flatten(); + + + this._register(autorun(r => { + // If we have a new entry for the file, then clear old decorators. + // User could be cycling through different edit sessions (Undo Last Edit / Redo Last Edit). + entryObs.read(r); + clearDecorators(); + })); + + this._register(autorun(r => { + // If there's no diff info, then we either accepted or rejected everything. + const diffs = diffInfoObs.read(r); + if (!diffs || !diffs.cellDiff.length) { + clearDecorators(); + this._ctxHasEditorModification.reset(); + } else { + this._ctxHasEditorModification.set(true); + } + })); + + this._register(autorun(r => { + const entry = entryObs.read(r); + const diffInfo = diffInfoObs.read(r); + const modified = notebookModel.read(r); + const original = originalModel.read(r); + onDidChangeVisibleRanges.read(r); + + if (!entry || !modified || !original || !diffInfo) { + return; + } + if (diffInfo && updatedCellDecoratorsOnceBefore && (diffInfo.modelVersion !== modified.versionId)) { + return; + } + + updatedCellDecoratorsOnceBefore = true; + diffInfo.cellDiff.forEach((diff) => { + if (diff.type === 'modified') { + const modifiedCell = modified.cells[diff.modifiedCellIndex]; + const originalCellValue = original.cells[diff.originalCellIndex].getValue(); + const editor = this.notebookEditor.codeEditors.find(([vm,]) => vm.handle === modifiedCell.handle)?.[1]; + if (editor && decorators.get(modifiedCell)?.editor !== editor) { + decorators.get(modifiedCell)?.dispose(); + const decorator = this.instantiationService.createInstance(NotebookCellDiffDecorator, editor, originalCellValue, modifiedCell.cellKind); + decorators.set(modifiedCell, decorator); + this._register(editor.onDidDispose(() => { + decorator.dispose(); + if (decorators.get(modifiedCell) === decorator) { + decorators.set(modifiedCell, decorator); + } + })); + } + } + }); + })); + + this._register(autorun(r => { + const entry = entryObs.read(r); + const diffInfo = diffInfoObs.read(r); + const modified = notebookModel.read(r); + const original = originalModel.read(r); + const vmAttached = viewModelAttached.read(r); + if (!vmAttached || !entry || !modified || !original || !diffInfo) { + return; + } + if (diffInfo && updatedDeletedInsertedDecoratorsOnceBefore && (diffInfo.modelVersion !== modified.versionId)) { + return; + } + updatedDeletedInsertedDecoratorsOnceBefore = true; + this.insertedCellDecorator.apply(diffInfo.cellDiff); + this.deletedCellDecorator.apply(diffInfo.cellDiff, original); + })); + } + +} diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalCellModelFactory.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalCellModelFactory.ts new file mode 100644 index 00000000000..c2a08c74da1 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalCellModelFactory.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { CellKind } from '../../common/notebookCommon.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; + + +export const INotebookOriginalCellModelFactory = createDecorator('INotebookOriginalCellModelFactory'); + +export interface INotebookOriginalCellModelFactory { + readonly _serviceBrand: undefined; + getOrCreate(uri: URI, cellValue: string, language: string, cellKind: CellKind): IReference; +} + + +export class OriginalNotebookCellModelReferenceCollection extends ReferenceCollection { + constructor(@IModelService private readonly modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService, + ) { + super(); + } + + protected override createReferencedObject(_key: string, uri: URI, cellValue: string, language: string, cellKind: CellKind): ITextModel { + const scheme = `${uri.scheme}-chat-edit`; + const originalCellUri = URI.from({ scheme, fragment: uri.fragment, path: uri.path }); + const languageSelection = this._languageService.getLanguageIdByLanguageName(language) ? this._languageService.createById(language) : cellKind === CellKind.Markup ? this._languageService.createById('markdown') : null; + return this.modelService.createModel(cellValue, languageSelection, originalCellUri); + } + protected override destroyReferencedObject(_key: string, model: ITextModel): void { + model.dispose(); + } +} + +export class OriginalNotebookCellModelFactory implements INotebookOriginalCellModelFactory { + readonly _serviceBrand: undefined; + private readonly _data: OriginalNotebookCellModelReferenceCollection; + constructor(@IInstantiationService instantiationService: IInstantiationService) { + this._data = instantiationService.createInstance(OriginalNotebookCellModelReferenceCollection); + } + + getOrCreate(uri: URI, cellValue: string, language: string, cellKind: CellKind): IReference { + return this._data.acquire(uri.toString(), uri, cellValue, language, cellKind); + } +} + + diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalModelRefFactory.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalModelRefFactory.ts new file mode 100644 index 00000000000..e0a38d61874 --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookOriginalModelRefFactory.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AsyncReferenceCollection, IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; +import { IModifiedFileEntry } from '../../../chat/common/chatEditingService.js'; +import { INotebookService } from '../../common/notebookService.js'; +import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; + + +export const INotebookOriginalModelReferenceFactory = createDecorator('INotebookOriginalModelReferenceFactory'); + +export interface INotebookOriginalModelReferenceFactory { + readonly _serviceBrand: undefined; + getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise>; +} + + +export class OriginalNotebookModelReferenceCollection extends ReferenceCollection> { + private readonly modelsToDispose = new Set(); + constructor(@INotebookService private readonly notebookService: INotebookService) { + super(); + } + + protected override async createReferencedObject(key: string, fileEntry: IModifiedFileEntry, viewType: string): Promise { + this.modelsToDispose.delete(key); + const uri = fileEntry.originalURI; + const model = this.notebookService.getNotebookTextModel(uri); + if (model) { + return model; + } + const bytes = VSBuffer.fromString(fileEntry.originalModel.getValue()); + const stream = bufferToStream(bytes); + + return this.notebookService.createNotebookTextModel(viewType, uri, stream); + } + protected override destroyReferencedObject(key: string, modelPromise: Promise): void { + this.modelsToDispose.add(key); + + (async () => { + try { + const model = await modelPromise; + + if (!this.modelsToDispose.has(key)) { + // return if model has been acquired again meanwhile + return; + } + + // Finally we can dispose the model + model.dispose(); + } catch (error) { + // ignore + } finally { + this.modelsToDispose.delete(key); // Untrack as being disposed + } + })(); + } +} + +export class NotebookOriginalModelReferenceFactory implements INotebookOriginalModelReferenceFactory { + readonly _serviceBrand: undefined; + private _resourceModelCollection: OriginalNotebookModelReferenceCollection & ReferenceCollection> /* TS Fail */ | undefined = undefined; + private get resourceModelCollection() { + if (!this._resourceModelCollection) { + this._resourceModelCollection = this.instantiationService.createInstance(OriginalNotebookModelReferenceCollection); + } + + return this._resourceModelCollection; + } + + private _asyncModelCollection: AsyncReferenceCollection | undefined = undefined; + private get asyncModelCollection() { + if (!this._asyncModelCollection) { + this._asyncModelCollection = new AsyncReferenceCollection(this.resourceModelCollection); + } + + return this._asyncModelCollection; + } + + constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { + } + + getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise> { + return this.asyncModelCollection.acquire(fileEntry.originalURI.toString(), fileEntry, viewType); + } +} + diff --git a/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookSynronizer.ts b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookSynronizer.ts new file mode 100644 index 00000000000..c7c2be4dc6b --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/chatEdit/notebookSynronizer.ts @@ -0,0 +1,358 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { Disposable, DisposableStore, IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; +import { IModifiedFileEntry } from '../../../chat/common/chatEditingService.js'; +import { INotebookService } from '../../common/notebookService.js'; +import { bufferToStream, VSBuffer } from '../../../../../base/common/buffer.js'; +import { NotebookTextModel } from '../../common/model/notebookTextModel.js'; +import { raceCancellation, ThrottledDelayer } from '../../../../../base/common/async.js'; +import { CellDiffInfo, computeDiff, prettyChanges } from '../diff/notebookDiffViewModel.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { INotebookEditorWorkerService } from '../../common/services/notebookWorkerService.js'; +import { ChatEditingModifiedFileEntry } from '../../../chat/browser/chatEditing/chatEditingModifiedFileEntry.js'; +import { CellEditType, ICellDto2, ICellReplaceEdit, NotebookData, NotebookSetting } from '../../common/notebookCommon.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { EditOperation } from '../../../../../editor/common/core/editOperation.js'; +import { INotebookLoggingService } from '../../common/notebookLoggingService.js'; +import { filter } from '../../../../../base/common/objects.js'; +import { INotebookEditorModelResolverService } from '../../common/notebookEditorModelResolverService.js'; +import { SaveReason } from '../../../../common/editor.js'; +import { IChatService } from '../../../chat/common/chatService.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotebookOriginalModelReferenceFactory } from './notebookOriginalModelRefFactory.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; + + +export const INotebookModelSynchronizerFactory = createDecorator('INotebookModelSynchronizerFactory'); + +export interface INotebookModelSynchronizerFactory { + readonly _serviceBrand: undefined; + getOrCreate(model: NotebookTextModel, entry: IModifiedFileEntry): IReference; +} + +class NotebookModelSynchronizerReferenceCollection extends ReferenceCollection { + constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { + super(); + } + protected override createReferencedObject(_key: string, model: NotebookTextModel, entry: IModifiedFileEntry): NotebookModelSynchronizer { + return this.instantiationService.createInstance(NotebookModelSynchronizer, model, entry); + } + protected override destroyReferencedObject(_key: string, object: NotebookModelSynchronizer): void { + object.dispose(); + } +} + +export class NotebookModelSynchronizerFactory implements INotebookModelSynchronizerFactory { + readonly _serviceBrand: undefined; + private readonly _data: NotebookModelSynchronizerReferenceCollection; + constructor(@IInstantiationService instantiationService: IInstantiationService) { + this._data = instantiationService.createInstance(NotebookModelSynchronizerReferenceCollection); + } + + getOrCreate(model: NotebookTextModel, entry: IModifiedFileEntry): IReference { + return this._data.acquire(entry.modifiedURI.toString(), model, entry); + } +} + + +export class NotebookModelSynchronizer extends Disposable { + private readonly throttledUpdateNotebookModel = new ThrottledDelayer(200); + private _diffInfo = observableValue<{ cellDiff: CellDiffInfo[]; modelVersion: number } | undefined>('diffInfo', undefined); + public get diffInfo(): IObservable<{ cellDiff: CellDiffInfo[]; modelVersion: number } | undefined> { + return this._diffInfo; + } + private snapshot?: { bytes: VSBuffer; dirty: boolean }; + private isEditFromUs: boolean = false; + constructor( + private readonly model: NotebookTextModel, + public readonly entry: IModifiedFileEntry, + @INotebookService private readonly notebookService: INotebookService, + @IChatService chatService: IChatService, + @INotebookLoggingService private readonly logService: INotebookLoggingService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @INotebookEditorWorkerService private readonly notebookEditorWorkerService: INotebookEditorWorkerService, + @INotebookEditorModelResolverService private readonly notebookModelResolverService: INotebookEditorModelResolverService, + @INotebookOriginalModelReferenceFactory private readonly originalModelRefFactory: INotebookOriginalModelReferenceFactory, + ) { + super(); + + this._register(chatService.onDidPerformUserAction(async e => { + if (e.action.kind === 'chatEditingSessionAction' && !e.action.hasRemainingEdits && isEqual(e.action.uri, entry.modifiedURI)) { + if (e.action.outcome === 'accepted') { + await this.accept(); + await this.createSnapshot(); + this._diffInfo.set(undefined, undefined); + } + else if (e.action.outcome === 'rejected') { + if (await this.revert()) { + this._diffInfo.set(undefined, undefined); + } + } + } + })); + + const cancellationTokenStore = this._register(new DisposableStore()); + let cancellationToken = cancellationTokenStore.add(new CancellationTokenSource()); + const updateNotebookModel = (entry: IModifiedFileEntry, token: CancellationToken) => { + this.throttledUpdateNotebookModel.trigger(() => this.updateNotebookModel(entry, token)); + }; + const modifiedModel = (entry as ChatEditingModifiedFileEntry).modifiedModel; + this._register(modifiedModel.onDidChangeContent(async () => { + cancellationTokenStore.clear(); + if (!modifiedModel.isDisposed() && !entry.originalModel.isDisposed() && modifiedModel.getValue() === entry.originalModel.getValue()) { + if (await this.revert()) { + this._diffInfo.set(undefined, undefined); + } + return; + } + cancellationToken = cancellationTokenStore.add(new CancellationTokenSource()); + updateNotebookModel(entry, cancellationToken.token); + })); + this._register(model.onDidChangeContent(() => { + // Track changes from the user. + if (!this.isEditFromUs && this.snapshot) { + this.snapshot.dirty = true; + } + })); + + updateNotebookModel(entry, cancellationToken.token); + + + } + + public async createSnapshot() { + const [serializer, ref] = await Promise.all([ + this.getNotebookSerializer(), + this.notebookModelResolverService.resolve(this.model.uri) + ]); + + try { + const data: NotebookData = { + metadata: filter(this.model.metadata, key => !serializer.options.transientDocumentMetadata[key]), + cells: [], + }; + + let outputSize = 0; + for (const cell of this.model.cells) { + const cellData: ICellDto2 = { + cellKind: cell.cellKind, + language: cell.language, + mime: cell.mime, + source: cell.getValue(), + outputs: [], + internalMetadata: cell.internalMetadata + }; + + const outputSizeLimit = this.configurationService.getValue(NotebookSetting.outputBackupSizeLimit) * 1024; + if (outputSizeLimit > 0) { + cell.outputs.forEach(output => { + output.outputs.forEach(item => { + outputSize += item.data.byteLength; + }); + }); + if (outputSize > outputSizeLimit) { + return; + } + } + + cellData.outputs = !serializer.options.transientOutputs ? cell.outputs : []; + cellData.metadata = filter(cell.metadata, key => !serializer.options.transientCellMetadata[key]); + + data.cells.push(cellData); + } + + const bytes = await serializer.notebookToData(data); + this.snapshot = { bytes, dirty: ref.object.isDirty() }; + } finally { + ref.dispose(); + } + } + + private async revert(): Promise { + if (!this.snapshot) { + return false; + } + await this.updateNotebook(this.snapshot.bytes, !this.snapshot.dirty); + return true; + } + + private async updateNotebook(bytes: VSBuffer, save: boolean) { + const ref = await this.notebookModelResolverService.resolve(this.model.uri); + try { + const serializer = await this.getNotebookSerializer(); + const data = await serializer.dataToNotebook(bytes); + this.model.reset(data.cells, data.metadata, serializer.options); + + if (save) { + // save the file after discarding so that the dirty indicator goes away + // and so that an intermediate saved state gets reverted + // await this.notebookEditor.textModel.save({ reason: SaveReason.EXPLICIT }); + await ref.object.save({ reason: SaveReason.EXPLICIT }); + } + } finally { + ref.dispose(); + } + } + + private async accept() { + const modifiedModel = (this.entry as ChatEditingModifiedFileEntry).modifiedModel; + const content = modifiedModel.getValue(); + await this.updateNotebook(VSBuffer.fromString(content), false); + } + + async getNotebookSerializer() { + const info = await this.notebookService.withNotebookDataProvider(this.model.viewType); + return info.serializer; + } + + private _originalModel?: Promise; + private async getOriginalModel(): Promise { + if (!this._originalModel) { + this._originalModel = this.originalModelRefFactory.getOrCreate(this.entry, this.model.viewType).then(ref => this._register(ref).object); + } + return this._originalModel; + } + private async updateNotebookModel(entry: IModifiedFileEntry, token: CancellationToken) { + const modifiedModelVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); + const currentModel = this.model; + const modelVersion = currentModel?.versionId ?? 0; + const modelWithChatEdits = await this.getModifiedModelForDiff(entry, token); + if (!modelWithChatEdits || token.isCancellationRequested || !currentModel) { + return; + } + const originalModel = await this.getOriginalModel(); + // This is the total diff from the original model to the model with chat edits. + const cellDiffInfo = (await this.computeDiff(originalModel, modelWithChatEdits, token))?.cellDiffInfo; + // This is the diff from the current model to the model with chat edits. + const cellDiffInfoToApplyEdits = (await this.computeDiff(currentModel, modelWithChatEdits, token))?.cellDiffInfo; + const currentVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); + if (!cellDiffInfo || !cellDiffInfoToApplyEdits || token.isCancellationRequested || currentVersion !== modifiedModelVersion || modelVersion !== currentModel.versionId) { + return; + } + if (cellDiffInfoToApplyEdits.every(d => d.type === 'unchanged')) { + return; + } + + // All edits from here on are from us. + this.isEditFromUs = true; + try { + const edits: ICellReplaceEdit[] = []; + const mappings = new Map(); + + // First Delete. + const deletedIndexes: number[] = []; + cellDiffInfoToApplyEdits.reverse().forEach(diff => { + if (diff.type === 'delete') { + deletedIndexes.push(diff.originalCellIndex); + edits.push({ + editType: CellEditType.Replace, + index: diff.originalCellIndex, + cells: [], + count: 1 + }); + } + }); + if (edits.length) { + currentModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + edits.length = 0; + } + + // Next insert. + cellDiffInfoToApplyEdits.reverse().forEach(diff => { + if (diff.type === 'modified' || diff.type === 'unchanged') { + mappings.set(diff.modifiedCellIndex, diff.originalCellIndex); + } + if (diff.type === 'insert') { + const originalIndex = mappings.get(diff.modifiedCellIndex - 1) ?? 0; + mappings.set(diff.modifiedCellIndex, originalIndex); + const cell = modelWithChatEdits.cells[diff.modifiedCellIndex]; + const newCell: ICellDto2 = + { + source: cell.getValue(), + cellKind: cell.cellKind, + language: cell.language, + outputs: cell.outputs.map(output => output.asDto()), + mime: cell.mime, + metadata: cell.metadata, + collapseState: cell.collapseState, + internalMetadata: cell.internalMetadata + }; + edits.push({ + editType: CellEditType.Replace, + index: originalIndex + 1, + cells: [newCell], + count: 0 + }); + } + }); + if (edits.length) { + currentModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + edits.length = 0; + } + + // Finally update + cellDiffInfoToApplyEdits.forEach(diff => { + if (diff.type === 'modified') { + const cell = currentModel.cells[diff.originalCellIndex]; + const textModel = cell.textModel; + if (textModel) { + const newText = modelWithChatEdits.cells[diff.modifiedCellIndex].getValue(); + textModel.pushEditOperations(null, [ + EditOperation.replace(textModel.getFullModelRange(), newText) + ], () => null); + } + } + }); + + if (edits.length) { + currentModel.applyEdits(edits, true, undefined, () => undefined, undefined, false); + } + this._diffInfo.set({ cellDiff: cellDiffInfo, modelVersion: currentModel.versionId }, undefined); + } + finally { + this.isEditFromUs = false; + } + } + private previousUriOfModelForDiff?: URI; + + private async getModifiedModelForDiff(entry: IModifiedFileEntry, token: CancellationToken): Promise { + const text = (entry as ChatEditingModifiedFileEntry).modifiedModel.getValue(); + const bytes = VSBuffer.fromString(text); + const uri = entry.modifiedURI.with({ scheme: `NotebookChatEditorController.modifiedScheme${Date.now().toString()}` }); + const stream = bufferToStream(bytes); + if (this.previousUriOfModelForDiff) { + this.notebookService.getNotebookTextModel(this.previousUriOfModelForDiff)?.dispose(); + } + this.previousUriOfModelForDiff = uri; + try { + const model = await this.notebookService.createNotebookTextModel(this.model.viewType, uri, stream); + if (token.isCancellationRequested) { + model.dispose(); + return; + } + this._register(model); + return model; + } catch (ex) { + this.logService.warn('NotebookChatEdit', `Failed to deserialize Notebook for ${uri.toString}, ${ex.message}`); + this.logService.debug('NotebookChatEdit', ex.toString()); + return; + } + } + + async computeDiff(original: NotebookTextModel, modified: NotebookTextModel, token: CancellationToken) { + const diffResult = await raceCancellation(this.notebookEditorWorkerService.computeDiff(original.uri, modified.uri), token); + if (!diffResult || token.isCancellationRequested) { + // after await the editor might be disposed. + return; + } + + prettyChanges(original, modified, diffResult.cellsDiff); + + return computeDiff(original, modified, diffResult); + } +} diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts index d27bbdf4300..7df058a6901 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/editorHint/emptyCellEditorHint.ts @@ -75,7 +75,7 @@ export class EmptyCellEditorHintContribution extends EmptyTextEditorHintContribu } const activeEditor = getNotebookEditorFromEditorPane(this._editorService.activeEditorPane); - if (!activeEditor) { + if (!activeEditor || !activeEditor.isDisposed) { return false; } diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditController.css b/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditController.css index 28f534a0b94..8bb9ac6cb1d 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditController.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditController.css @@ -7,12 +7,25 @@ padding: 12px 16px; } +/** Cell delete higlight */ .monaco-workbench .notebookOverlay > .cell-list-container > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .view-zones .cell-inner-container { background-color: var(--vscode-diffEditor-removedLineBackground); padding: 8px 0; margin-bottom: 16px; } +/** Cell insert higlight */ +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .cell-focus-indicator, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.markdown-cell-row.nb-insertHighlight { + background-color: var(--vscode-diffEditor-insertedLineBackground, var(--vscode-diffEditor-insertedTextBackground)) !important; +} + +.notebookOverlay .cell .cell-statusbar-container .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .cell-focus-indicator .cell-inner-container, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .monaco-editor-background, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .margin-view-overlays, +.monaco-workbench .notebookOverlay .monaco-list .monaco-list-row.code-cell-row.nb-insertHighlight .cell-statusbar-container { + background-color: var(--vscode-diffEditor-insertedLineBackground, var(--vscode-diffEditor-insertedTextBackground)) !important; +} .monaco-workbench .notebookOverlay .view-zones .cell-editor-part { outline: solid 1px var(--vscode-notebook-cellBorderColor); diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditorOverlay.css b/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditorOverlay.css new file mode 100644 index 00000000000..e6b3e30c1bd --- /dev/null +++ b/src/vs/workbench/contrib/notebook/browser/media/notebookChatEditorOverlay.css @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.notebook-chat-editor-overlay-widget { + position: absolute; + /** Based on chat widget for regular editors **/ + right: 28px; + /** Based on chat widget for regular editors **/ + bottom: 23px; + /** In notebook.css we set this to 22px, we need to revert this to standards **/ + line-height: 1.4em; +} + +/** Copied from src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css **/ +/** Copied until we unify these, for now its separate **/ +.notebook-chat-editor-overlay-widget { + padding: 0px; + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); + border-radius: 5px; + border: 1px solid var(--vscode-contrastBorder); + display: flex; + align-items: center; + z-index: 10; +} + +.notebook-chat-editor-overlay-widget .chat-editor-overlay-progress { + display: none; + padding: 0px 5px; + font-size: 12px; +} + +.notebook-chat-editor-overlay-widget.busy .chat-editor-overlay-progress { + display: inherit; +} + +.notebook-chat-editor-overlay-widget .action-item > .action-label { + padding: 5px; + font-size: 12px; +} + +.notebook-chat-editor-overlay-widget .action-item:first-child > .action-label { + padding-left: 9px; +} + +.notebook-chat-editor-overlay-widget .action-item:last-child > .action-label { + padding-right: 9px; +} + +.notebook-chat-editor-overlay-widget.busy .chat-editor-overlay-progress .codicon, +.notebook-chat-editor-overlay-widget .action-item > .action-label.codicon { + color: var(--vscode-button-foreground); +} + +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label.codicon::before, +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label.codicon, +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label, +.notebook-chat-editor-overlay-widget .action-item.disabled > .action-label:hover { + color: var(--vscode-button-foreground); + opacity: 0.7; +} diff --git a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts index eda8b6caa1c..603e8808ebd 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts @@ -130,8 +130,11 @@ import { NotebookMultiDiffEditorInput } from './diff/notebookMultiDiffEditorInpu import { getFormattedMetadataJSON } from '../common/model/notebookCellTextModel.js'; import { INotebookOutlineEntryFactory, NotebookOutlineEntryFactory } from './viewModel/notebookOutlineEntryFactory.js'; import { getFormattedNotebookMetadataJSON } from '../common/model/notebookMetadataTextModel.js'; -import { INotebookOriginalModelReferenceFactory, NotebookChatEditorControllerContrib, NotebookOriginalModelReferenceFactory } from './notebookChatEditController.js'; +import { NotebookChatEditorControllerContrib } from './chatEdit/notebookChatEditController.js'; import { registerNotebookContribution } from './notebookEditorExtensions.js'; +import { INotebookOriginalModelReferenceFactory, NotebookOriginalModelReferenceFactory } from './chatEdit/notebookOriginalModelRefFactory.js'; +import { INotebookModelSynchronizerFactory, NotebookModelSynchronizerFactory } from './chatEdit/notebookSynronizer.js'; +import { INotebookOriginalCellModelFactory, OriginalNotebookCellModelFactory } from './chatEdit/notebookOriginalCellModelFactory.js'; /*--------------------------------------------------------------------------------------------- */ @@ -880,6 +883,8 @@ registerSingleton(INotebookOutlineEntryFactory, NotebookOutlineEntryFactory, Ins registerNotebookContribution(NotebookChatEditorControllerContrib.ID, NotebookChatEditorControllerContrib); registerSingleton(INotebookOriginalModelReferenceFactory, NotebookOriginalModelReferenceFactory, InstantiationType.Delayed); +registerSingleton(INotebookModelSynchronizerFactory, NotebookModelSynchronizerFactory, InstantiationType.Delayed); +registerSingleton(INotebookOriginalCellModelFactory, OriginalNotebookCellModelFactory, InstantiationType.Delayed); const schemas: IJSONSchemaMap = {}; function isConfigurationPropertySchema(x: IConfigurationPropertySchema | { [path: string]: IConfigurationPropertySchema }): x is IConfigurationPropertySchema { diff --git a/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts index 72dc16c6631..73b9edbadf3 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts @@ -39,6 +39,9 @@ export class NotebookAccessibilityProvider extends Disposable implements IListAc (last: executionUpdate[] | undefined, e: ICellExecutionStateChangedEvent | IExecutionStateChangedEvent) => this.mergeEvents(last, e), 100 )((updates: executionUpdate[]) => { + if (!updates.length) { + return; + } const viewModel = this.viewModel(); if (viewModel) { for (const update of updates) { @@ -52,7 +55,7 @@ export class NotebookAccessibilityProvider extends Disposable implements IListAc if (this.shouldReadCellOutputs(lastUpdate.state)) { const cell = viewModel.getCellByHandle(lastUpdate.cellHandle); if (cell && cell.outputsViewModels.length) { - const text = getAllOutputsText(viewModel.notebookDocument, cell); + const text = getAllOutputsText(viewModel.notebookDocument, cell, true); alert(text); } } diff --git a/src/vs/workbench/contrib/notebook/browser/notebookChatEditController.ts b/src/vs/workbench/contrib/notebook/browser/notebookChatEditController.ts deleted file mode 100644 index 7e8a9003f03..00000000000 --- a/src/vs/workbench/contrib/notebook/browser/notebookChatEditController.ts +++ /dev/null @@ -1,939 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { isEqual } from '../../../../base/common/resources.js'; -import { AsyncReferenceCollection, Disposable, DisposableStore, dispose, IReference, ReferenceCollection, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, autorunWithStore, derived, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; -import { IChatEditingService, WorkingSetEntryState, IModifiedFileEntry, ChatEditingSessionState } from '../../chat/common/chatEditingService.js'; -import { INotebookService } from '../common/notebookService.js'; -import { bufferToStream, VSBuffer } from '../../../../base/common/buffer.js'; -import { NotebookTextModel } from '../common/model/notebookTextModel.js'; -import { INotebookEditor, INotebookEditorContribution } from './notebookBrowser.js'; -import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { raceCancellation, ThrottledDelayer } from '../../../../base/common/async.js'; -import { CellDiffInfo, computeDiff, prettyChanges } from './diff/notebookDiffViewModel.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { INotebookEditorWorkerService } from '../common/services/notebookWorkerService.js'; -import { ChatEditingModifiedFileEntry } from '../../chat/browser/chatEditing/chatEditingModifiedFileEntry.js'; -import { CellEditType, CellKind, CellUri, ICellDto2, ICellReplaceEdit, NotebookData, NotebookSetting } from '../common/notebookCommon.js'; -import { URI } from '../../../../base/common/uri.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { ICodeEditor, IViewZone } from '../../../../editor/browser/editorBrowser.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js'; -import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; -import { themeColorFromId } from '../../../../base/common/themables.js'; -import { RenderOptions, LineSource, renderLines } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; -import { diffAddDecoration, diffWholeLineAddDecoration, diffDeleteDecoration } from '../../../../editor/browser/widget/diffEditor/registrations.contribution.js'; -import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; -import { ITextModel, TrackedRangeStickiness, MinimapPosition, IModelDeltaDecoration, OverviewRulerLane } from '../../../../editor/common/model.js'; -import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; -import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel.js'; -import { overviewRulerModifiedForeground, minimapGutterModifiedBackground, overviewRulerAddedForeground, minimapGutterAddedBackground, overviewRulerDeletedForeground, minimapGutterDeletedBackground } from '../../scm/browser/dirtydiffDecorator.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { NotebookCellTextModel } from '../common/model/notebookCellTextModel.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { EditOperation } from '../../../../editor/common/core/editOperation.js'; -import { INotebookLoggingService } from '../common/notebookLoggingService.js'; -import { TextEdit } from '../../../../editor/common/core/textEdit.js'; -import { Position } from '../../../../editor/common/core/position.js'; -import { DetailedLineRangeMapping, RangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; -import { tokenizeToString } from '../../../../editor/common/languages/textToHtmlTokenizer.js'; -import * as DOM from '../../../../base/browser/dom.js'; -import { createTrustedTypesPolicy } from '../../../../base/browser/trustedTypes.js'; -import { splitLines } from '../../../../base/common/strings.js'; -import { DefaultLineHeight } from './diff/diffElementViewModel.js'; -import { filter } from '../../../../base/common/objects.js'; -import { INotebookEditorModelResolverService } from '../common/notebookEditorModelResolverService.js'; -import { SaveReason } from '../../../common/editor.js'; - - -export const INotebookOriginalModelReferenceFactory = createDecorator('INotebookOriginalModelReferenceFactory'); - -export interface INotebookOriginalModelReferenceFactory { - readonly _serviceBrand: undefined; - getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise>; -} - - -export class NotebookChatEditorControllerContrib extends Disposable implements INotebookEditorContribution { - - public static readonly ID: string = 'workbench.notebook.chatEditorController'; - readonly _serviceBrand: undefined; - constructor( - notebookEditor: INotebookEditor, - @IInstantiationService instantiationService: IInstantiationService, - @IConfigurationService configurationService: IConfigurationService, - - ) { - super(); - if (configurationService.getValue('notebook.experimental.chatEdits')) { - this._register(instantiationService.createInstance(NotebookChatEditorController, notebookEditor)); - } - } -} - - -class NotebookChatEditorController extends Disposable { - private readonly deletedCellOverlayer: NotebookDeletedCellOverlayer; - constructor( - private readonly notebookEditor: INotebookEditor, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @INotebookOriginalModelReferenceFactory private readonly originalModelRefFactory: INotebookOriginalModelReferenceFactory, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(); - this.deletedCellOverlayer = this._register(instantiationService.createInstance(NotebookDeletedCellOverlayer, notebookEditor)); - const notebookModel = observableFromEvent(this.notebookEditor.onDidChangeModel, e => e); - const entryObs = observableValue('fileentry', undefined); - const notebookDiff = observableValue<{ cellDiff: CellDiffInfo[]; modelVersion: number } | undefined>('cellDiffInfo', undefined); - const originalModel = observableValue('originalModel', undefined); - this._register(toDisposable(() => { - disposeDecorators(); - })); - this._register(autorun(r => { - const session = this._chatEditingService.currentEditingSessionObs.read(r); - const model = notebookModel.read(r); - if (!model || !session) { - return; - } - const entry = session.entries.read(r).find(e => isEqual(e.modifiedURI, model.uri)); - - if (!entry || entry.state.read(r) !== WorkingSetEntryState.Modified) { - disposeDecorators(); - return; - } - // If we have a new entry for the file, then clear old decorators. - // User could be cycling through different edit sessions (Undo Last Edit / Redo Last Edit). - if (entryObs.read(r) && entryObs.read(r) !== entry) { - disposeDecorators(); - } - entryObs.set(entry, undefined); - })); - - this._register(autorunWithStore(async (r, store) => { - const entry = entryObs.read(r); - const model = notebookModel.read(r); - if (!entry || !model) { - return; - } - const notebookSynchronizer = store.add(this.instantiationService.createInstance(NotebookModelSynchronizer, this.notebookEditor, entry, model.viewType)); - notebookDiff.set(undefined, undefined); - await notebookSynchronizer.createSnapshot(); - store.add(notebookSynchronizer.onDidUpdateNotebookModel(e => { - notebookDiff.set(e, undefined); - })); - store.add(notebookSynchronizer.onDidRevert(e => { - if (e) { - disposeDecorators(); - this.deletedCellOverlayer.clear(); - } - })); - const result = this._register(await this.originalModelRefFactory.getOrCreate(entry, model.viewType)); - originalModel.set(result.object, undefined); - })); - - const onDidChangeVisibleRanges = observableFromEvent(this.notebookEditor.onDidChangeVisibleRanges, () => this.notebookEditor.visibleRanges); - const decorators = new Map(); - const disposeDecorators = () => { - dispose(Array.from(decorators.values())); - decorators.clear(); - }; - this._register(autorun(r => { - const entry = entryObs.read(r); - const diffInfo = notebookDiff.read(r); - const modified = notebookModel.read(r); - const original = originalModel.read(r); - onDidChangeVisibleRanges.read(r); - - if (!entry || !modified || !original || !diffInfo || diffInfo.modelVersion !== modified.versionId) { - return; - } - - diffInfo.cellDiff.forEach((diff) => { - if (diff.type === 'modified' || diff.type === 'insert') { - const modifiedCell = modified.cells[diff.modifiedCellIndex]; - const originalCellValue = diff.type === 'modified' ? original.cells[diff.originalCellIndex].getValue() : undefined; - const editor = this.notebookEditor.codeEditors.find(([vm,]) => vm.handle === modifiedCell.handle)?.[1]; - if (editor && decorators.get(modifiedCell)?.editor !== editor) { - decorators.get(modifiedCell)?.dispose(); - const decorator = this.instantiationService.createInstance(NotebookCellDiffDecorator, editor, originalCellValue, modifiedCell.cellKind); - decorators.set(modifiedCell, decorator); - this._register(editor.onDidDispose(() => { - decorator.dispose(); - if (decorators.get(modifiedCell) === decorator) { - decorators.set(modifiedCell, decorator); - } - })); - } - } - }); - })); - this._register(autorun(r => { - const entry = entryObs.read(r); - const diffInfo = notebookDiff.read(r); - const modified = notebookModel.read(r); - const original = originalModel.read(r); - if (!entry || !modified || !original || (diffInfo && diffInfo.modelVersion !== modified.versionId)) { - return; - } - if (!diffInfo) { - // User reverted the changes, hence original === modified. - this.deletedCellOverlayer.clear(); - return; - } - this.deletedCellOverlayer.createNecessaryOverlays(diffInfo.cellDiff, original); - })); - } -} - - -class NotebookCellDiffDecorator extends DisposableStore { - private readonly _decorations = this.editor.createDecorationsCollection(); - private _viewZones: string[] = []; - private readonly throttledDecorator = new ThrottledDelayer(100); - - constructor( - public readonly editor: ICodeEditor, - private readonly originalCellValue: string | undefined, - private readonly cellKind: CellKind, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IModelService private readonly modelService: IModelService, - @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, - @ILanguageService private readonly _languageService: ILanguageService, - - ) { - super(); - this.add(this.editor.onDidChangeModel(() => this.update())); - this.add(this.editor.onDidChangeConfiguration((e) => { - if (e.hasChanged(EditorOption.fontInfo) || e.hasChanged(EditorOption.lineHeight)) { - this.update(); - } - })); - - const shouldBeReadOnly = derived(this, r => { - const value = this._chatEditingService.currentEditingSessionObs.read(r); - if (!value || value.state.read(r) !== ChatEditingSessionState.StreamingEdits) { - return false; - } - return value.entries.read(r).some(e => isEqual(e.modifiedURI, this.editor.getModel()?.uri)); - }); - - - let actualReadonly: boolean | undefined; - let actualDeco: 'off' | 'editable' | 'on' | undefined; - - this.add(autorun(r => { - const value = shouldBeReadOnly.read(r); - if (value) { - actualReadonly ??= this.editor.getOption(EditorOption.readOnly); - actualDeco ??= this.editor.getOption(EditorOption.renderValidationDecorations); - - this.editor.updateOptions({ - readOnly: true, - renderValidationDecorations: 'off' - }); - } else { - if (actualReadonly !== undefined && actualDeco !== undefined) { - this.editor.updateOptions({ - readOnly: actualReadonly, - renderValidationDecorations: actualDeco - }); - actualReadonly = undefined; - actualDeco = undefined; - } - } - })); - this.update(); - } - - override dispose(): void { - this._clearRendering(); - super.dispose(); - } - - public update(): void { - this.throttledDecorator.trigger(() => this._updateImpl()); - } - - private async _updateImpl() { - if (this.isDisposed) { - return; - } - if (!this.editor.hasModel()) { - this._clearRendering(); - return; - } - if (this.editor.getOption(EditorOption.inDiffEditor)) { - this._clearRendering(); - return; - } - const model = this.editor.getModel(); - if (!model) { - this._clearRendering(); - return; - } - - const version = model.getVersionId(); - const originalModel = this.getOrCreateOriginalModel(); - const diff = originalModel ? await this.computeDiff() : undefined; - if (this.isDisposed) { - return; - } - - if ((originalModel && !diff) || model !== this.editor.getModel() || this.editor.getModel()?.getVersionId() !== version) { - this._clearRendering(); - } - - if (diff && originalModel) { - this._updateWithDiff(originalModel, diff); - } else { - const edit = TextEdit.insert(new Position(0, 0), model.getValue()); - const rangeMapping = RangeMapping.fromEdit(edit); - const insertDiff: IDocumentDiff = { - identical: false, - moves: [], - quitEarly: false, - changes: [DetailedLineRangeMapping.fromRangeMappings(rangeMapping)], - }; - this._updateWithDiff(undefined, insertDiff); - } - } - - private _clearRendering() { - this.editor.changeViewZones((viewZoneChangeAccessor) => { - for (const id of this._viewZones) { - viewZoneChangeAccessor.removeZone(id); - } - }); - this._viewZones = []; - this._decorations.clear(); - } - - private _originalModel?: ITextModel; - private getOrCreateOriginalModel() { - if (this._originalModel) { - return this._originalModel; - } - if (!this.originalCellValue) { - return; - } - const model = this.editor.getModel(); - if (!model) { - return; - } - const cellUri = model.uri; - const languageId = model.getLanguageId(); - - const scheme = `${CellUri.scheme}-chat-edit`; - const originalCellUri = URI.from({ scheme, fragment: cellUri.fragment, path: cellUri.path }); - const languageSelection = this._languageService.getLanguageIdByLanguageName(languageId) ? this._languageService.createById(languageId) : this.cellKind === CellKind.Markup ? this._languageService.createById('markdown') : null; - return this._originalModel = this.add(this.modelService.createModel(this.originalCellValue, languageSelection, originalCellUri)); - } - private async computeDiff() { - const model = this.editor.getModel(); - if (!model) { - return; - } - const originalModel = this.getOrCreateOriginalModel(); - if (!originalModel) { - return; - } - - return this._editorWorkerService.computeDiff( - originalModel.uri, - model.uri, - { computeMoves: true, ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER }, - 'advanced' - ); - } - - private _updateWithDiff(originalModel: ITextModel | undefined, diff: IDocumentDiff): void { - const chatDiffAddDecoration = ModelDecorationOptions.createDynamic({ - ...diffAddDecoration, - stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges - }); - const chatDiffWholeLineAddDecoration = ModelDecorationOptions.createDynamic({ - ...diffWholeLineAddDecoration, - stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, - }); - const createOverviewDecoration = (overviewRulerColor: string, minimapColor: string) => { - return ModelDecorationOptions.createDynamic({ - description: 'chat-editing-decoration', - overviewRuler: { color: themeColorFromId(overviewRulerColor), position: OverviewRulerLane.Left }, - minimap: { color: themeColorFromId(minimapColor), position: MinimapPosition.Gutter }, - }); - }; - const modifiedDecoration = createOverviewDecoration(overviewRulerModifiedForeground, minimapGutterModifiedBackground); - const addedDecoration = createOverviewDecoration(overviewRulerAddedForeground, minimapGutterAddedBackground); - const deletedDecoration = createOverviewDecoration(overviewRulerDeletedForeground, minimapGutterDeletedBackground); - - this.editor.changeViewZones((viewZoneChangeAccessor) => { - for (const id of this._viewZones) { - viewZoneChangeAccessor.removeZone(id); - } - this._viewZones = []; - const modifiedDecorations: IModelDeltaDecoration[] = []; - const mightContainNonBasicASCII = originalModel?.mightContainNonBasicASCII(); - const mightContainRTL = originalModel?.mightContainRTL(); - const renderOptions = RenderOptions.fromEditor(this.editor); - - for (const diffEntry of diff.changes) { - const originalRange = diffEntry.original; - if (originalModel) { - originalModel.tokenization.forceTokenization(Math.max(1, originalRange.endLineNumberExclusive - 1)); - } - const source = new LineSource( - (originalRange.length && originalModel) ? originalRange.mapToLineArray(l => originalModel.tokenization.getLineTokens(l)) : [], - [], - mightContainNonBasicASCII, - mightContainRTL, - ); - const decorations: InlineDecoration[] = []; - for (const i of diffEntry.innerChanges || []) { - decorations.push(new InlineDecoration( - i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)), - diffDeleteDecoration.className!, - InlineDecorationType.Regular - )); - modifiedDecorations.push({ - range: i.modifiedRange, options: chatDiffAddDecoration - }); - } - if (!diffEntry.modified.isEmpty) { - modifiedDecorations.push({ - range: diffEntry.modified.toInclusiveRange()!, options: chatDiffWholeLineAddDecoration - }); - } - - if (diffEntry.original.isEmpty) { - // insertion - modifiedDecorations.push({ - range: diffEntry.modified.toInclusiveRange()!, - options: addedDecoration - }); - } else if (diffEntry.modified.isEmpty) { - // deletion - modifiedDecorations.push({ - range: new Range(diffEntry.modified.startLineNumber - 1, 1, diffEntry.modified.startLineNumber, 1), - options: deletedDecoration - }); - } else { - // modification - modifiedDecorations.push({ - range: diffEntry.modified.toInclusiveRange()!, - options: modifiedDecoration - }); - } - const domNode = document.createElement('div'); - domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text'; - const result = renderLines(source, renderOptions, decorations, domNode); - - const isCreatedContent = decorations.length === 1 && decorations[0].range.isEmpty() && decorations[0].range.startLineNumber === 1; - if (!isCreatedContent) { - const viewZoneData: IViewZone = { - afterLineNumber: diffEntry.modified.startLineNumber - 1, - heightInLines: result.heightInLines, - domNode, - ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42 - }; - - this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData)); - } - } - - this._decorations.set(modifiedDecorations); - }); - } -} - - -class NotebookModelSynchronizer extends Disposable { - private readonly throttledUpdateNotebookModel = new ThrottledDelayer(200); - private readonly _onDidUpdateNotebookModel = this._register(new Emitter<{ cellDiff: CellDiffInfo[]; modelVersion: number }>); - public readonly onDidUpdateNotebookModel = this._onDidUpdateNotebookModel.event; - private readonly _onDidRevert = this._register(new Emitter()); - public readonly onDidRevert = this._onDidRevert.event; - private snapshot?: { bytes: VSBuffer; dirty: boolean }; - private isEditFromUs: boolean = false; - constructor( - private readonly notebookEditor: INotebookEditor, - public readonly entry: IModifiedFileEntry, - private readonly viewType: string, - @INotebookService private readonly notebookService: INotebookService, - @INotebookLoggingService private readonly logService: INotebookLoggingService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @INotebookEditorWorkerService private readonly notebookEditorWorkerService: INotebookEditorWorkerService, - @INotebookEditorModelResolverService private readonly notebookModelResolverService: INotebookEditorModelResolverService, - ) { - super(); - const cancellationTokenStore = this._register(new DisposableStore()); - let cancellationToken = cancellationTokenStore.add(new CancellationTokenSource()); - const updateNotebookModel = (entry: IModifiedFileEntry, viewType: string, token: CancellationToken) => { - this.throttledUpdateNotebookModel.trigger(() => this.updateNotebookModel(entry, viewType, token)); - }; - const modelObs = observableFromEvent(notebookEditor.onDidChangeModel, e => e); - const modifiedModel = (entry as ChatEditingModifiedFileEntry).modifiedModel; - this._register(modifiedModel.onDidChangeContent(() => { - cancellationTokenStore.clear(); - if (!modifiedModel.isDisposed() && !entry.originalModel.isDisposed() && modifiedModel.getValue() === entry.originalModel.getValue()) { - this.revert(); - return; - } - cancellationToken = cancellationTokenStore.add(new CancellationTokenSource()); - updateNotebookModel(entry, viewType, cancellationToken.token); - })); - this._register(autorunWithStore((r, store) => { - const model = modelObs.read(r); - if (model) { - store.add(model.onDidChangeContent(() => { - // Track changes from the user. - if (!this.isEditFromUs && this.snapshot) { - this.snapshot.dirty = true; - } - })); - } - })); - - updateNotebookModel(entry, viewType, cancellationToken.token); - - - } - - public async createSnapshot() { - const model = this.notebookEditor.textModel; - if (!model) { - return; - } - const [serializer, ref] = await Promise.all([ - this.getNotebookSerializer(), - this.notebookModelResolverService.resolve(this.notebookEditor.textModel.uri) - ]); - - try { - const data: NotebookData = { - metadata: filter(model.metadata, key => !serializer.options.transientDocumentMetadata[key]), - cells: [], - }; - - let outputSize = 0; - for (const cell of model.cells) { - const cellData: ICellDto2 = { - cellKind: cell.cellKind, - language: cell.language, - mime: cell.mime, - source: cell.getValue(), - outputs: [], - internalMetadata: cell.internalMetadata - }; - - const outputSizeLimit = this.configurationService.getValue(NotebookSetting.outputBackupSizeLimit) * 1024; - if (outputSizeLimit > 0) { - cell.outputs.forEach(output => { - output.outputs.forEach(item => { - outputSize += item.data.byteLength; - }); - }); - if (outputSize > outputSizeLimit) { - return; - } - } - - cellData.outputs = !serializer.options.transientOutputs ? cell.outputs : []; - cellData.metadata = filter(cell.metadata, key => !serializer.options.transientCellMetadata[key]); - - data.cells.push(cellData); - } - - const bytes = await serializer.notebookToData(data); - this.snapshot = { bytes, dirty: ref.object.isDirty() }; - } finally { - ref.dispose(); - } - } - - public async revert(): Promise { - if (!this.snapshot || !this.notebookEditor.textModel) { - return; - } - - const ref = await this.notebookModelResolverService.resolve(this.notebookEditor.textModel.uri); - try { - const serializer = await this.getNotebookSerializer(); - const data = await serializer.dataToNotebook(this.snapshot.bytes); - this.notebookEditor.textModel.reset(data.cells, data.metadata, serializer.options); - - if (!this.snapshot.dirty) { - // save the file after discarding so that the dirty indicator goes away - // and so that an intermediate saved state gets reverted - // await this.notebookEditor.textModel.save({ reason: SaveReason.EXPLICIT }); - await ref.object.save({ reason: SaveReason.EXPLICIT }); - } - this._onDidRevert.fire(true); - } finally { - ref.dispose(); - } - } - - async getNotebookSerializer() { - const info = await this.notebookService.withNotebookDataProvider(this.viewType); - return info.serializer; - } - - private async updateNotebookModel(entry: IModifiedFileEntry, viewType: string, token: CancellationToken) { - const modifiedModelVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); - const original = this.notebookEditor.textModel; - const originalModelVersion = original?.versionId ?? 0; - const model = await this.getModifiedModelForDiff(entry, viewType, token); - if (!model || token.isCancellationRequested || !original) { - return; - } - const cellDiffInfo = (await this.computeDiff(original, model, token))?.cellDiffInfo; - const currentVersion = (entry as ChatEditingModifiedFileEntry).modifiedModel.getVersionId(); - if (!cellDiffInfo || token.isCancellationRequested || currentVersion !== modifiedModelVersion || originalModelVersion !== original.versionId) { - return; - } - if (cellDiffInfo.every(d => d.type === 'unchanged')) { - return; - } - - // All edits from here on are from us. - this.isEditFromUs = true; - try { - const edits: ICellReplaceEdit[] = []; - const mappings = new Map(); - - // First Delete. - const deletedIndexes: number[] = []; - cellDiffInfo.reverse().forEach(diff => { - if (diff.type === 'delete') { - deletedIndexes.push(diff.originalCellIndex); - edits.push({ - editType: CellEditType.Replace, - index: diff.originalCellIndex, - cells: [], - count: 1 - }); - } - }); - if (edits.length) { - original.applyEdits(edits, true, undefined, () => undefined, undefined, false); - edits.length = 0; - } - - // Next insert. - cellDiffInfo.reverse().forEach(diff => { - if (diff.type === 'modified' || diff.type === 'unchanged') { - mappings.set(diff.modifiedCellIndex, diff.originalCellIndex); - } - if (diff.type === 'insert') { - const originalIndex = mappings.get(diff.modifiedCellIndex - 1) ?? 0; - mappings.set(diff.modifiedCellIndex, originalIndex); - const cell = model.cells[diff.modifiedCellIndex]; - const newCell: ICellDto2 = - { - source: cell.getValue(), - cellKind: cell.cellKind, - language: cell.language, - outputs: cell.outputs.map(output => output.asDto()), - mime: cell.mime, - metadata: cell.metadata, - collapseState: cell.collapseState, - internalMetadata: cell.internalMetadata - }; - edits.push({ - editType: CellEditType.Replace, - index: originalIndex + 1, - cells: [newCell], - count: 0 - }); - } - }); - if (edits.length) { - original.applyEdits(edits, true, undefined, () => undefined, undefined, false); - edits.length = 0; - } - - // Finally update - cellDiffInfo.forEach(diff => { - if (diff.type === 'modified') { - const cell = original.cells[diff.originalCellIndex]; - const textModel = cell.textModel; - if (textModel) { - const newText = model.cells[diff.modifiedCellIndex].getValue(); - textModel.pushEditOperations(null, [ - EditOperation.replace(textModel.getFullModelRange(), newText) - ], () => null); - } - } - }); - - if (edits.length) { - original.applyEdits(edits, true, undefined, () => undefined, undefined, false); - } - this._onDidRevert.fire(false); - this._onDidUpdateNotebookModel.fire({ cellDiff: cellDiffInfo, modelVersion: original.versionId }); - } - finally { - this.isEditFromUs = false; - } - } - private previousUriOfModelForDiff?: URI; - - private async getModifiedModelForDiff(entry: IModifiedFileEntry, viewType: string, token: CancellationToken): Promise { - const text = (entry as ChatEditingModifiedFileEntry).modifiedModel.getValue(); - const bytes = VSBuffer.fromString(text); - const uri = entry.modifiedURI.with({ scheme: `NotebookChatEditorController.modifiedScheme${Date.now().toString()}` }); - const stream = bufferToStream(bytes); - if (this.previousUriOfModelForDiff) { - this.notebookService.getNotebookTextModel(this.previousUriOfModelForDiff)?.dispose(); - } - this.previousUriOfModelForDiff = uri; - try { - const model = await this.notebookService.createNotebookTextModel(viewType, uri, stream); - if (token.isCancellationRequested) { - model.dispose(); - return; - } - this._register(model); - return model; - } catch (ex) { - this.logService.warn('NotebookChatEdit', `Failed to deserialize Notebook for ${uri.toString}, ${ex.message}`); - this.logService.debug('NotebookChatEdit', ex.toString()); - return; - } - } - - async computeDiff(original: NotebookTextModel, modified: NotebookTextModel, token: CancellationToken) { - const diffResult = await raceCancellation(this.notebookEditorWorkerService.computeDiff(original.uri, modified.uri), token); - if (!diffResult || token.isCancellationRequested) { - // after await the editor might be disposed. - return; - } - - prettyChanges(original, modified, diffResult.cellsDiff); - - return computeDiff(original, modified, diffResult); - } -} - -const ttPolicy = createTrustedTypesPolicy('notebookChatEditController', { createHTML: value => value }); - -class NotebookDeletedCellOverlayer extends Disposable { - private readonly zoneRemover = this._register(new DisposableStore()); - private readonly createdViewZones = new Map(); - constructor( - private readonly _notebookEditor: INotebookEditor, - @ILanguageService private readonly languageService: ILanguageService, - ) { - super(); - } - - - public createNecessaryOverlays(diffInfo: CellDiffInfo[], original: NotebookTextModel): void { - this.clear(); - - let currentIndex = 0; - const deletedCellsToRender: { cells: NotebookCellTextModel[]; index: number } = { cells: [], index: 0 }; - diffInfo.forEach(diff => { - if (diff.type === 'delete') { - const deletedCell = original.cells[diff.originalCellIndex]; - if (deletedCell) { - deletedCellsToRender.cells.push(deletedCell); - deletedCellsToRender.index = currentIndex; - } - } else { - if (deletedCellsToRender.cells.length) { - this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); - deletedCellsToRender.cells.length = 0; - } - currentIndex = diff.modifiedCellIndex; - } - }); - if (deletedCellsToRender.cells.length) { - this._createWidget(deletedCellsToRender.index + 1, deletedCellsToRender.cells); - } - } - - public clear() { - this.zoneRemover.clear(); - } - - - private _createWidget(index: number, cells: NotebookCellTextModel[]) { - this._createWidgetImpl(index, cells); - } - private async _createWidgetImpl(index: number, cells: NotebookCellTextModel[]) { - const rootContainer = document.createElement('div'); - const widgets = cells.map(cell => new NotebookDeletedCellWidget(this._notebookEditor, cell.getValue(), cell.language, rootContainer, this.languageService)); - const heights = await Promise.all(widgets.map(w => w.render())); - const totalHeight = heights.reduce((prev, curr) => prev + curr, 0); - - this._notebookEditor.changeViewZones(accessor => { - const notebookViewZone = { - afterModelPosition: index, - heightInPx: totalHeight, - domNode: rootContainer - }; - - const id = accessor.addZone(notebookViewZone); - accessor.layoutZone(id); - this.createdViewZones.set(index, id); - this.zoneRemover.add(toDisposable(() => { - if (this.createdViewZones.get(index) === id) { - this.createdViewZones.delete(index); - } - if (!this._notebookEditor.isDisposed) { - this._notebookEditor.changeViewZones(accessor => { - accessor.removeZone(id); - dispose(widgets); - }); - } - })); - }); - } - -} - - -export class OriginalNotebookModelReferenceCollection extends ReferenceCollection> { - private readonly modelsToDispose = new Set(); - constructor(@INotebookService private readonly notebookService: INotebookService) { - super(); - } - - protected override async createReferencedObject(key: string, fileEntry: IModifiedFileEntry, viewType: string): Promise { - this.modelsToDispose.delete(key); - const uri = fileEntry.originalURI; - const model = this.notebookService.getNotebookTextModel(uri); - if (model) { - return model; - } - const bytes = VSBuffer.fromString(fileEntry.originalModel.getValue()); - const stream = bufferToStream(bytes); - - return this.notebookService.createNotebookTextModel(viewType, uri, stream); - } - protected override destroyReferencedObject(key: string, modelPromise: Promise): void { - this.modelsToDispose.add(key); - - (async () => { - try { - const model = await modelPromise; - - if (!this.modelsToDispose.has(key)) { - // return if model has been acquired again meanwhile - return; - } - - // Finally we can dispose the model - model.dispose(); - } catch (error) { - // ignore - } finally { - this.modelsToDispose.delete(key); // Untrack as being disposed - } - })(); - } -} - -class NotebookDeletedCellWidget extends Disposable { - private readonly container: HTMLElement; - constructor( - private readonly _notebookEditor: INotebookEditor, - // private readonly _index: number, - private readonly code: string, - private readonly language: string, - container: HTMLElement, - @ILanguageService private readonly languageService: ILanguageService, - ) { - super(); - this.container = DOM.append(container, document.createElement('div')); - this._register(toDisposable(() => { - container.removeChild(this.container); - })); - } - - public async render() { - const code = this.code; - const languageId = this.language; - const codeHtml = await tokenizeToString(this.languageService, code, languageId); - - // const colorMap = this.getDefaultColorMap(); - const fontInfo = this._notebookEditor.getBaseCellEditorOptions(languageId).value; - const fontFamilyVar = '--notebook-editor-font-family'; - const fontSizeVar = '--notebook-editor-font-size'; - const fontWeightVar = '--notebook-editor-font-weight'; - // If we have any editors, then use left layout of one of those. - const editor = this._notebookEditor.codeEditors.map(c => c[1]).find(c => c); - const layoutInfo = editor?.getOptions().get(EditorOption.layoutInfo); - - const style = `` - + `font-family: var(${fontFamilyVar});` - + `font-weight: var(${fontWeightVar});` - + `font-size: var(${fontSizeVar});` - + fontInfo.lineHeight ? `line-height: ${fontInfo.lineHeight}px;` : '' - + layoutInfo?.contentLeft ? `margin-left: ${layoutInfo}px;` : '' - + `white-space: pre;`; - - - - const rootContainer = this.container; - rootContainer.classList.add('code-cell-row'); - const container = DOM.append(rootContainer, DOM.$('.cell-inner-container')); - const focusIndicatorLeft = DOM.append(container, DOM.$('.cell-focus-indicator.cell-focus-indicator-side.cell-focus-indicator-left')); - const cellContainer = DOM.append(container, DOM.$('.cell.code')); - DOM.append(focusIndicatorLeft, DOM.$('div.execution-count-label')); - const editorPart = DOM.append(cellContainer, DOM.$('.cell-editor-part')); - let editorContainer = DOM.append(editorPart, DOM.$('.cell-editor-container')); - editorContainer = DOM.append(editorContainer, DOM.$('.code', { style })); - if (fontInfo.fontFamily) { - editorContainer.style.setProperty(fontFamilyVar, fontInfo.fontFamily); - } - if (fontInfo.fontSize) { - editorContainer.style.setProperty(fontSizeVar, `${fontInfo.fontSize}px`); - } - if (fontInfo.fontWeight) { - editorContainer.style.setProperty(fontWeightVar, fontInfo.fontWeight); - } - editorContainer.innerHTML = (ttPolicy?.createHTML(codeHtml) || codeHtml) as string; - - const lineCount = splitLines(code).length; - const height = (lineCount * (fontInfo.lineHeight || DefaultLineHeight)) + 12 + 12; // We have 12px top and bottom in generated code HTML; - const totalHeight = height + 16; - - return totalHeight; - } -} - -export class NotebookOriginalModelReferenceFactory implements INotebookOriginalModelReferenceFactory { - readonly _serviceBrand: undefined; - private _resourceModelCollection: OriginalNotebookModelReferenceCollection & ReferenceCollection> /* TS Fail */ | undefined = undefined; - private get resourceModelCollection() { - if (!this._resourceModelCollection) { - this._resourceModelCollection = this.instantiationService.createInstance(OriginalNotebookModelReferenceCollection); - } - - return this._resourceModelCollection; - } - - private _asyncModelCollection: AsyncReferenceCollection | undefined = undefined; - private get asyncModelCollection() { - if (!this._asyncModelCollection) { - this._asyncModelCollection = new AsyncReferenceCollection(this.resourceModelCollection); - } - - return this._asyncModelCollection; - } - - constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { - } - - getOrCreate(fileEntry: IModifiedFileEntry, viewType: string): Promise> { - return this.asyncModelCollection.acquire(fileEntry.originalURI.toString(), fileEntry, viewType); - } -} - diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 8b863d6051c..cc59086ef53 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -18,6 +18,7 @@ import './media/notebookEditorStickyScroll.css'; import './media/notebookKernelActionViewItem.css'; import './media/notebookOutline.css'; import './media/notebookChatEditController.css'; +import './media/notebookChatEditorOverlay.css'; import * as DOM from '../../../../base/browser/dom.js'; import { IMouseWheelEvent, StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts index 3f540054100..3eb68a713a5 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/cellOutputTextHelper.ts @@ -15,7 +15,7 @@ interface Error { stack?: string; } -export function getAllOutputsText(notebook: NotebookTextModel, viewCell: ICellViewModel): string { +export function getAllOutputsText(notebook: NotebookTextModel, viewCell: ICellViewModel, shortErrors: boolean = false): string { const outputText: string[] = []; for (let i = 0; i < viewCell.outputsViewModels.length; i++) { const outputViewModel = viewCell.outputsViewModels[i]; @@ -40,7 +40,7 @@ export function getAllOutputsText(notebook: NotebookTextModel, viewCell: ICellVi i += count - 1; } } else { - text = getOutputText(mimeType, buffer); + text = getOutputText(mimeType, buffer, shortErrors); } outputText.push(text); @@ -80,7 +80,7 @@ export function getOutputStreamText(output: ICellOutputViewModel): { text: strin const decoder = new TextDecoder(); -export function getOutputText(mimeType: string, buffer: IOutputItemDto) { +export function getOutputText(mimeType: string, buffer: IOutputItemDto, shortError: boolean = false): string { let text = `${mimeType}`; // default in case we can't get the text value for some reason. const charLimit = 100000; @@ -92,10 +92,10 @@ export function getOutputText(mimeType: string, buffer: IOutputItemDto) { text = text.replace(/\\u001b\[[0-9;]*m/gi, ''); try { const error = JSON.parse(text) as Error; - if (error.stack) { - text = error.stack; - } else { + if (!error.stack || shortError) { text = `${error.name}: ${error.message}`; + } else { + text = error.stack; } } catch { // just use raw text diff --git a/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts b/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts index 0df311b425f..af913c22053 100644 --- a/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts +++ b/src/vs/workbench/contrib/replNotebook/browser/repl.contribution.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { alert } from '../../../../base/browser/ui/aria/aria.js'; import { Event } from '../../../../base/common/event.js'; import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; @@ -18,7 +17,6 @@ import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/c import { PLAINTEXT_LANGUAGE_ID } from '../../../../editor/common/languages/modesRegistry.js'; import { localize2 } from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; -import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../../../platform/accessibility/common/accessibility.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -46,9 +44,8 @@ import { INotebookEditorOptions } from '../../notebook/browser/notebookBrowser.j import { NotebookEditorWidget } from '../../notebook/browser/notebookEditorWidget.js'; import * as icons from '../../notebook/browser/notebookIcons.js'; import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; -import { getAllOutputsText } from '../../notebook/browser/viewModel/cellOutputTextHelper.js'; import { CellEditType, CellKind, NotebookSetting, NotebookWorkingCopyTypeIdentifier, REPL_EDITOR_ID } from '../../notebook/common/notebookCommon.js'; -import { IS_COMPOSITE_NOTEBOOK, MOST_RECENT_REPL_EDITOR, NOTEBOOK_CELL_LIST_FOCUSED } from '../../notebook/common/notebookContextKeys.js'; +import { IS_COMPOSITE_NOTEBOOK, MOST_RECENT_REPL_EDITOR, NOTEBOOK_CELL_LIST_FOCUSED, NOTEBOOK_EDITOR_FOCUSED } from '../../notebook/common/notebookContextKeys.js'; import { NotebookEditorInputOptions } from '../../notebook/common/notebookEditorInput.js'; import { INotebookEditorModelResolverService } from '../../notebook/common/notebookEditorModelResolverService.js'; import { INotebookService } from '../../notebook/common/notebookService.js'; @@ -238,6 +235,11 @@ registerAction2(class extends Action2 { id: MenuId.CommandPalette, when: MOST_RECENT_REPL_EDITOR, }, + keybinding: [{ + primary: KeyChord(KeyMod.Alt | KeyCode.End, KeyMod.Alt | KeyCode.End), + weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, + when: ContextKeyExpr.or(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_CELL_LIST_FOCUSED.negate()) + }], precondition: MOST_RECENT_REPL_EDITOR }); } @@ -284,33 +286,34 @@ registerAction2(class extends Action2 { registerAction2(class extends Action2 { constructor() { super({ - id: 'repl.readLastExecutionOutput', - title: localize2('repl.readMostRecentExecution', 'Read Most Recent Execution Output'), + id: 'repl.input.focus', + title: localize2('repl.input.focus', 'Focus Input Editor'), category: 'REPL', - keybinding: [{ - primary: KeyChord(KeyMod.Alt | KeyCode.End, KeyMod.Alt | KeyCode.End), - weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT - }], menu: { id: MenuId.CommandPalette, when: MOST_RECENT_REPL_EDITOR, }, - precondition: ContextKeyExpr.and( - ContextKeyExpr.or(IS_COMPOSITE_NOTEBOOK || NOTEBOOK_CELL_LIST_FOCUSED.negate()), - MOST_RECENT_REPL_EDITOR, - CONTEXT_ACCESSIBILITY_MODE_ENABLED) + keybinding: [{ + when: ContextKeyExpr.and(IS_COMPOSITE_NOTEBOOK, NOTEBOOK_EDITOR_FOCUSED), + weight: NOTEBOOK_EDITOR_WIDGET_ACTION_WEIGHT, + primary: KeyMod.CtrlCmd | KeyCode.DownArrow + }, { + when: ContextKeyExpr.and(MOST_RECENT_REPL_EDITOR), + weight: KeybindingWeight.WorkbenchContrib + 5, + primary: KeyChord(KeyMod.Alt | KeyCode.Home, KeyMod.Alt | KeyCode.Home), + }] }); } - async run(accessor: ServicesAccessor, context?: UriComponents): Promise { + async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); const editorControl = editorService.activeEditorPane?.getControl(); const contextKeyService = accessor.get(IContextKeyService); - let notebookEditor: NotebookEditorWidget | undefined; - if (editorControl && isReplEditorControl(editorControl)) { - notebookEditor = editorControl.notebookEditor; - } else { + if (editorControl && isReplEditorControl(editorControl) && editorControl.notebookEditor) { + editorService.activeEditorPane?.focus(); + } + else { const uriString = MOST_RECENT_REPL_EDITOR.getValue(contextKeyService); const uri = uriString ? URI.parse(uriString) : undefined; @@ -320,22 +323,7 @@ registerAction2(class extends Action2 { const replEditor = editorService.findEditors(uri)[0]; if (replEditor) { - const editor = await editorService.openEditor({ resource: uri, options: { preserveFocus: true } }, replEditor.groupId); - const editorControl = editor?.getControl(); - - if (editorControl && isReplEditorControl(editorControl)) { - notebookEditor = editorControl.notebookEditor; - } - } - } - - const viewModel = notebookEditor?.getViewModel(); - const notebook = notebookEditor?.textModel; - if (viewModel && notebook) { - const cell = viewModel.getMostRecentlyExecutedCell(); - if (cell) { - const text = getAllOutputsText(notebook, cell); - alert(text); + await editorService.openEditor({ resource: uri, options: { preserveFocus: false } }, replEditor.groupId); } } } diff --git a/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts b/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts index 9e422ebc95c..318738dd33b 100644 --- a/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts +++ b/src/vs/workbench/contrib/replNotebook/browser/replEditor.ts @@ -59,6 +59,8 @@ import { ReplInputHintContentWidget } from '../../interactive/browser/replInputH import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { localize } from '../../../../nls.js'; +import { NotebookViewModel } from '../../notebook/browser/viewModel/notebookViewModelImpl.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; const INTERACTIVE_EDITOR_VIEW_STATE_PREFERENCE_KEY = 'InteractiveEditorViewState'; @@ -129,7 +131,8 @@ export class ReplEditor extends EditorPane implements IEditorPaneWithScrolling { @IEditorGroupsService editorGroupService: IEditorGroupsService, @ITextResourceConfigurationService textResourceConfigurationService: ITextResourceConfigurationService, @INotebookExecutionStateService notebookExecutionStateService: INotebookExecutionStateService, - @IExtensionService extensionService: IExtensionService + @IExtensionService extensionService: IExtensionService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService ) { super( REPL_EDITOR_ID, @@ -540,13 +543,31 @@ export class ReplEditor extends EditorPane implements IEditorPaneWithScrolling { if (addedCells.length) { const viewModel = notebookWidget.viewModel; if (viewModel) { - this._notebookWidgetService.updateReplContextKey(viewModel.notebookDocument.uri.toString()); + this.handleAppend(notebookWidget, viewModel); break; } } } } + private handleAppend(notebookWidget: NotebookEditorWidget, viewModel: NotebookViewModel) { + this._notebookWidgetService.updateReplContextKey(viewModel.notebookDocument.uri.toString()); + const navigateToCell = this._configurationService.getValue('accessibility.replEditor.autoFocusReplExecution'); + if (this._accessibilityService.isScreenReaderOptimized()) { + if (navigateToCell === 'lastExecution') { + setTimeout(() => { + const lastCellIndex = viewModel.length - 1; + if (lastCellIndex >= 0) { + const cell = viewModel.viewCells[lastCellIndex]; + notebookWidget.focusNotebookCell(cell, 'container'); + } + }, 0); + } else if (navigateToCell === 'input') { + this._codeEditorWidget.focus(); + } + } + } + override setOptions(options: INotebookEditorOptions | undefined): void { this._notebookWidget.value?.setOptions(options); super.setOptions(options); diff --git a/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts b/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts index d8ca3ffc19e..35cab6df38d 100644 --- a/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/replNotebook/browser/replEditorAccessibilityHelp.ts @@ -34,13 +34,12 @@ function getAccessibilityHelpText(): string { return [ localize('replEditor.overview', 'You are in a REPL Editor which contains in input box to evaluate expressions and a list of previously executed expressions and their output.'), localize('replEditor.execute', 'The Execute command{0} will evaluate the expression in the input box.', ''), - localize('replEditor.readLastExecution', 'The Read Last Execution Output command{0} will read the output of the last executed item.', ''), localize('replEditor.configReadExecution', 'The setting `accessibility.replEditor.readLastExecutionOutput` controls if output will be automatically read when execution completes.'), - localize('replEditor.focusHistory', 'The Focus History command{0} will move focus to the list of previously executed items.', ''), + localize('replEditor.autoFocusRepl', 'The setting `accessibility.replEditor.autoFocusReplExecution` controls if focus will automatically move to the REPL after executing code.'), + localize('replEditor.focusLastItemAdded', 'The Focus Last executed command{0} will move focus to the last executed item in the REPL history.', ''), localize('replEditor.accessibilityView', 'Run the Open Accessbility View command{0} while navigating the history for an accessible view of the item\'s output.', ''), - localize('replEditor.focusLastItemAdded', 'The Focus Last executed command{0} will move focus to the last executed item without needing to first focus on the editor.', ''), - localize('replEditor.focusReplInput', 'The Focus Input Editor command{0} will move focus to the REPL input box.', ''), - localize('replEditor.cellNavigation', 'The up and down arrows will also move focus between previously executed items.'), + localize('replEditor.cellNavigation', 'The up and down arrows will also move focus between previously executed items while focused on the REPL history.'), + localize('replEditor.focusReplInput', 'The Focus Input Editor command{0} will move focus to the REPL input box.', ''), localize('replEditor.focusInOutput', 'The Focus Output command{0} will set focus on the output when focused on a previously executed item.', ''), ].join('\n'); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 028b6d9ddd2..59e0c822f4e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; -import * as cssJs from '../../../../base/browser/cssValue.js'; +import * as cssValue from '../../../../base/browser/cssValue.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { debounce, memoize } from '../../../../base/common/decorators.js'; import { DynamicListEventMultiplexer, Emitter, Event, IDynamicListEventMultiplexer } from '../../../../base/common/event.js'; @@ -1258,8 +1258,8 @@ class TerminalEditorStyle extends Themable { const iconClasses = getUriClasses(instance, colorTheme.type); if (uri instanceof URI && iconClasses && iconClasses.length > 1) { css += ( - `.monaco-workbench .terminal-tab.${iconClasses[0]}::before` + - `{content: ''; background-image: ${cssJs.asCSSUrl(uri)};}` + cssValue.inline`.monaco-workbench .terminal-tab.${cssValue.className(iconClasses[0])}::before + {content: ''; background-image: ${cssValue.asCSSUrl(uri)};}` ); } if (ThemeIcon.isThemeIcon(icon)) { @@ -1268,10 +1268,8 @@ class TerminalEditorStyle extends Themable { if (iconContribution) { const def = productIconTheme.getIcon(iconContribution); if (def) { - css += ( - `.monaco-workbench .terminal-tab.codicon-${icon.id}::before` + - `{content: '${def.fontCharacter}' !important; font-family: ${cssJs.asCSSPropertyValue(def.font?.id ?? 'codicon')} !important;}` - ); + css += cssValue.inline`.monaco-workbench .terminal-tab.codicon-${cssValue.className(icon.id)}::before + {content: ${cssValue.stringValue(def.fontCharacter)} !important; font-family: ${cssValue.stringValue(def.font?.id ?? 'codicon')} !important;}`; } } } @@ -1280,7 +1278,7 @@ class TerminalEditorStyle extends Themable { // Add colors const iconForegroundColor = colorTheme.getColor(iconForeground); if (iconForegroundColor) { - css += `.monaco-workbench .show-file-icons .file-icon.terminal-tab::before { color: ${iconForegroundColor}; }`; + css += cssValue.inline`.monaco-workbench .show-file-icons .file-icon.terminal-tab::before { color: ${iconForegroundColor}; }`; } css += getColorStyleContent(colorTheme, true); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 4fbd07f9058..0acb96d7ed3 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -29,6 +29,7 @@ import { ISimpleSelectedSuggestion, SimpleSuggestWidget } from '../../../../serv import type { ISimpleSuggestWidgetFontInfo } from '../../../../services/suggest/browser/simpleSuggestWidgetRenderer.js'; import { ITerminalCompletionService } from './terminalCompletionService.js'; import { TerminalShellType } from '../../../../../platform/terminal/common/terminal.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; export interface ISuggestController { isPasting: boolean; @@ -67,6 +68,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest private _lastUserData?: string; static lastAcceptedCompletionTimestamp: number = 0; + private _cancellationTokenSource: CancellationTokenSource | undefined; + isPasting: boolean = false; private readonly _onBell = this._register(new Emitter()); @@ -113,7 +116,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest })); } - private async _handleCompletionProviders(terminal?: Terminal): Promise { + private async _handleCompletionProviders(terminal: Terminal | undefined, token: CancellationToken): Promise { // Nothing to handle if the terminal is not attached if (!terminal?.element || !this._enableWidget || !this._promptInputModel) { return; @@ -129,7 +132,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest } this._requestedCompletionsIndex = this._promptInputModel.cursorIndex; const providedCompletions = await this._terminalCompletionService.provideCompletions(this._promptInputModel.value, this._promptInputModel.cursorIndex, this._shellType); - if (!providedCompletions?.length) { + if (!providedCompletions?.length || token.isCancellationRequested) { return; } this._onDidReceiveCompletions.fire(); @@ -183,6 +186,9 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest const lineContext = new LineContext(normalizedLeadingLineContent, this._cursorIndexDelta); const model = new SimpleCompletionModel(completions.filter(c => !!c.label).map(c => new SimpleCompletionItem(c)), lineContext); + if (token.isCancellationRequested) { + return; + } this._showCompletions(model); } @@ -202,8 +208,13 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest if (this.isPasting) { return; } - - await this._handleCompletionProviders(this._terminal); + if (this._cancellationTokenSource) { + this._cancellationTokenSource.cancel(); + this._cancellationTokenSource.dispose(); + } + this._cancellationTokenSource = new CancellationTokenSource(); + const token = this._cancellationTokenSource.token; + await this._handleCompletionProviders(this._terminal, token); } private _sync(promptInputState: IPromptInputModelState): void { @@ -463,6 +474,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest } hideSuggestWidget(): void { + this._cancellationTokenSource?.cancel(); + this._cancellationTokenSource = undefined; this._currentPromptInputState = undefined; this._leadingLineContent = undefined; this._suggestWidget?.hide(); diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index 4785e6c5b7c..c7f9cb61c25 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -865,7 +865,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo private registerManageSyncAction(): void { const that = this; - const when = ContextKeyExpr.and(CONTEXT_SYNC_ENABLEMENT, CONTEXT_ACCOUNT_STATE.isEqualTo(AccountStatus.Available), CONTEXT_SYNC_STATE.notEqualsTo(SyncStatus.Uninitialized)); + const when = ContextKeyExpr.and(CONTEXT_SYNC_ENABLEMENT, CONTEXT_ACCOUNT_STATE.notEqualsTo(AccountStatus.Unavailable), CONTEXT_SYNC_STATE.notEqualsTo(SyncStatus.Uninitialized)); this._register(registerAction2(class SyncStatusAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/services/configuration/browser/configuration.ts b/src/vs/workbench/services/configuration/browser/configuration.ts index d18242bde57..217084c9e9b 100644 --- a/src/vs/workbench/services/configuration/browser/configuration.ts +++ b/src/vs/workbench/services/configuration/browser/configuration.ts @@ -133,7 +133,7 @@ export class ApplicationConfiguration extends UserSettings { uriIdentityService: IUriIdentityService, logService: ILogService, ) { - super(userDataProfilesService.defaultProfile.settingsResource, { scopes: [ConfigurationScope.APPLICATION] }, uriIdentityService.extUri, fileService, logService); + super(userDataProfilesService.defaultProfile.settingsResource, { scopes: [ConfigurationScope.APPLICATION], skipUnregistered: true }, uriIdentityService.extUri, fileService, logService); this._register(this.onDidChange(() => this.reloadConfigurationScheduler.schedule())); this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.loadConfiguration().then(configurationModel => this._onDidChangeConfiguration.fire(configurationModel)), 50)); } diff --git a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts index ebcc88dd25d..833dec287e0 100644 --- a/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/workbench/services/configuration/test/browser/configurationService.test.ts @@ -1737,6 +1737,12 @@ suite('WorkspaceConfigurationService - Profiles', () => { assert.strictEqual(testObject.getValue('configurationService.profiles.applicationSetting3'), 'defaultProfile'); })); + test('non registering setting should not be read from default profile', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(instantiationService.get(IUserDataProfilesService).defaultProfile.settingsResource, VSBuffer.fromString('{ "configurationService.profiles.nonregistered": "defaultProfile" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('configurationService.profiles.nonregistered'), undefined); + })); + test('initialize with custom all profiles settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { await testObject.updateValue(APPLY_ALL_PROFILES_SETTING, ['configurationService.profiles.testSetting2'], ConfigurationTarget.USER_LOCAL); diff --git a/src/vs/workbench/services/decorations/browser/decorationsService.ts b/src/vs/workbench/services/decorations/browser/decorationsService.ts index 49213f302d3..1417b4dc7c9 100644 --- a/src/vs/workbench/services/decorations/browser/decorationsService.ts +++ b/src/vs/workbench/services/decorations/browser/decorationsService.ts @@ -11,7 +11,7 @@ import { IDisposable, toDisposable, DisposableStore } from '../../../../base/com import { isThenable } from '../../../../base/common/async.js'; import { LinkedList } from '../../../../base/common/linkedList.js'; import { createStyleSheet, createCSSRule, removeCSSRulesContainingSelector } from '../../../../base/browser/dom.js'; -import { asCSSPropertyValue } from '../../../../base/browser/cssValue.js'; +import * as cssValue from '../../../../base/browser/cssValue.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { isFalsyOrWhitespace } from '../../../../base/common/strings.js'; @@ -139,7 +139,7 @@ class DecorationRule { `.${this.iconBadgeClassName}::after`, `content: '${definition.fontCharacter}'; color: ${icon.color ? getColor(icon.color.id) : getColor(color)}; - font-family: ${asCSSPropertyValue(definition.font?.id ?? 'codicon')}; + font-family: ${cssValue.stringValue(definition.font?.id ?? 'codicon')}; font-size: 16px; margin-right: 14px; font-weight: normal; diff --git a/src/vs/workbench/services/extensions/browser/extensionService.ts b/src/vs/workbench/services/extensions/browser/extensionService.ts index 818514fdd22..69aad42da57 100644 --- a/src/vs/workbench/services/extensions/browser/extensionService.ts +++ b/src/vs/workbench/services/extensions/browser/extensionService.ts @@ -26,7 +26,7 @@ import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/e import { IWebExtensionsScannerService, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../extensionManagement/common/extensionManagement.js'; import { IWebWorkerExtensionHostDataProvider, IWebWorkerExtensionHostInitData, WebWorkerExtensionHost } from './webWorkerExtensionHost.js'; import { FetchFileSystemProvider } from './webWorkerFileSystemProvider.js'; -import { AbstractExtensionService, IExtensionHostFactory, ResolvedExtensions, checkEnabledAndProposedAPI } from '../common/abstractExtensionService.js'; +import { AbstractExtensionService, IExtensionHostFactory, LocalExtensions, RemoteExtensions, ResolvedExtensions, ResolverExtensions, checkEnabledAndProposedAPI, isResolverExtension } from '../common/abstractExtensionService.js'; import { ExtensionDescriptionRegistrySnapshot } from '../common/extensionDescriptionRegistry.js'; import { ExtensionHostKind, ExtensionRunningPreference, IExtensionHostKindPicker, extensionHostKindToString, extensionRunningPreferenceToString } from '../common/extensionHostKind.js'; import { IExtensionManifestPropertiesService } from '../common/extensionManifestPropertiesService.js'; @@ -41,6 +41,7 @@ import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { IRemoteExplorerService } from '../../remote/common/remoteExplorerService.js'; import { IUserDataInitializationService } from '../../userData/browser/userDataInit.js'; import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js'; +import { AsyncIterableEmitter, AsyncIterableObject } from '../../../../base/common/async.js'; export class ExtensionService extends AbstractExtensionService implements IExtensionService { @@ -80,6 +81,7 @@ export class ExtensionService extends AbstractExtensionService implements IExten logService ); super( + { hasLocalProcess: false, allowRemoteExtensionsInLocalWebWorker: true }, extensionsProposedApi, extensionHostFactory, new BrowserExtensionHostKindPicker(logService), @@ -117,32 +119,45 @@ export class ExtensionService extends AbstractExtensionService implements IExten this._register(this._fileService.registerProvider(Schemas.https, provider)); } + private _scanWebExtensionsPromise: Promise | undefined; private async _scanWebExtensions(): Promise { - const system: IExtensionDescription[] = [], user: IExtensionDescription[] = [], development: IExtensionDescription[] = []; - try { - await Promise.all([ - this._webExtensionsScannerService.scanSystemExtensions().then(extensions => system.push(...extensions.map(e => toExtensionDescription(e)))), - this._webExtensionsScannerService.scanUserExtensions(this._userDataProfileService.currentProfile.extensionsResource, { skipInvalidExtensions: true }).then(extensions => user.push(...extensions.map(e => toExtensionDescription(e)))), - this._webExtensionsScannerService.scanExtensionsUnderDevelopment().then(extensions => development.push(...extensions.map(e => toExtensionDescription(e, true)))) - ]); - } catch (error) { - this._logService.error(error); + if (!this._scanWebExtensionsPromise) { + this._scanWebExtensionsPromise = (async () => { + const system: IExtensionDescription[] = [], user: IExtensionDescription[] = [], development: IExtensionDescription[] = []; + try { + await Promise.all([ + this._webExtensionsScannerService.scanSystemExtensions().then(extensions => system.push(...extensions.map(e => toExtensionDescription(e)))), + this._webExtensionsScannerService.scanUserExtensions(this._userDataProfileService.currentProfile.extensionsResource, { skipInvalidExtensions: true }).then(extensions => user.push(...extensions.map(e => toExtensionDescription(e)))), + this._webExtensionsScannerService.scanExtensionsUnderDevelopment().then(extensions => development.push(...extensions.map(e => toExtensionDescription(e, true)))) + ]); + } catch (error) { + this._logService.error(error); + } + return dedupExtensions(system, user, [], development, this._logService); + })(); } - return dedupExtensions(system, user, [], development, this._logService); + return this._scanWebExtensionsPromise; } - protected async _resolveExtensionsDefault() { + private async _resolveExtensionsDefault(emitter: AsyncIterableEmitter) { const [localExtensions, remoteExtensions] = await Promise.all([ this._scanWebExtensions(), this._remoteExtensionsScannerService.scanExtensions() ]); - return new ResolvedExtensions(localExtensions, remoteExtensions, /*hasLocalProcess*/false, /*allowRemoteExtensionsInLocalWebWorker*/true); + if (remoteExtensions.length) { + emitter.emitOne(new RemoteExtensions(remoteExtensions)); + } + emitter.emitOne(new LocalExtensions(localExtensions)); } - protected async _resolveExtensions(): Promise { + protected _resolveExtensions(): AsyncIterable { + return new AsyncIterableObject(emitter => this._doResolveExtensions(emitter)); + } + + private async _doResolveExtensions(emitter: AsyncIterableEmitter): Promise { if (!this._browserEnvironmentService.expectsResolverExtension) { - return this._resolveExtensionsDefault(); + return this._resolveExtensionsDefault(emitter); } const remoteAuthority = this._environmentService.remoteAuthority!; @@ -152,6 +167,11 @@ export class ExtensionService extends AbstractExtensionService implements IExten // override the trust state through the resolver result. await this._workspaceTrustManagementService.workspaceResolved; + const localExtensions = await this._scanWebExtensions(); + const resolverExtensions = localExtensions.filter(extension => isResolverExtension(extension)); + if (resolverExtensions.length) { + emitter.emitOne(new ResolverExtensions(resolverExtensions)); + } let resolverResult: ResolverResult; try { @@ -163,7 +183,7 @@ export class ExtensionService extends AbstractExtensionService implements IExten this._remoteAuthorityResolverService._setResolvedAuthorityError(remoteAuthority, err); // Proceed with the local extension host - return this._resolveExtensionsDefault(); + return this._resolveExtensionsDefault(emitter); } // set the resolved authority @@ -181,7 +201,7 @@ export class ExtensionService extends AbstractExtensionService implements IExten connection.onReconnecting(() => this._resolveAuthorityAgain()); } - return this._resolveExtensionsDefault(); + return this._resolveExtensionsDefault(emitter); } protected async _onExtensionHostExit(code: number): Promise { diff --git a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts index b14b72523de..68b71d7ce1b 100644 --- a/src/vs/workbench/services/extensions/common/abstractExtensionService.ts +++ b/src/vs/workbench/services/extensions/common/abstractExtensionService.ts @@ -61,6 +61,9 @@ export abstract class AbstractExtensionService extends Disposable implements IEx public _serviceBrand: undefined; + private readonly _hasLocalProcess: boolean; + private readonly _allowRemoteExtensionsInLocalWebWorker: boolean; + private readonly _onDidRegisterExtensions = this._register(new Emitter()); public readonly onDidRegisterExtensions = this._onDidRegisterExtensions.event; @@ -95,6 +98,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx private _resolveAuthorityAttempt: number = 0; constructor( + options: { hasLocalProcess: boolean; allowRemoteExtensionsInLocalWebWorker: boolean }, private readonly _extensionsProposedApi: ExtensionsProposedApi, private readonly _extensionHostFactory: IExtensionHostFactory, private readonly _extensionHostKindPicker: IExtensionHostKindPicker, @@ -118,6 +122,9 @@ export abstract class AbstractExtensionService extends Disposable implements IEx ) { super(); + this._hasLocalProcess = options.hasLocalProcess; + this._allowRemoteExtensionsInLocalWebWorker = options.allowRemoteExtensionsInLocalWebWorker; + // help the file service to activate providers by activating extensions by file system event this._register(this._fileService.onWillActivateFileSystemProvider(e => { if (e.scheme !== Schemas.vscodeRemote) { @@ -318,7 +325,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx this._extensionsProposedApi.updateEnabledApiProposals(toAdd); // Update extension points - this._doHandleExtensionPoints(([]).concat(toAdd).concat(toRemove)); + this._doHandleExtensionPoints(([]).concat(toAdd).concat(toRemove), false); // Update the extension host await this._updateExtensionsOnExtHosts(result.versionId, toAdd, toRemove.map(e => e.identifier)); @@ -453,10 +460,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx const lock = await this._registry.acquireLock('_initialize'); try { - const resolvedExtensions = await this._resolveExtensions(); - - this._processExtensions(lock, resolvedExtensions); - + await this._resolveAndProcessExtensions(lock); // Start extension hosts which are not automatically started const snapshot = this._registry.getSnapshot(); for (const extHostManager of this._extensionHostManagers) { @@ -474,10 +478,24 @@ export abstract class AbstractExtensionService extends Disposable implements IEx await this._handleExtensionTests(); } - private _processExtensions(lock: ExtensionDescriptionRegistryLock, resolvedExtensions: ResolvedExtensions): void { - const { allowRemoteExtensionsInLocalWebWorker, hasLocalProcess } = resolvedExtensions; - const localExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, resolvedExtensions.local, false); - let remoteExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, resolvedExtensions.remote, false); + private async _resolveAndProcessExtensions(lock: ExtensionDescriptionRegistryLock,): Promise { + let resolverExtensions: IExtensionDescription[] = []; + let localExtensions: IExtensionDescription[] = []; + let remoteExtensions: IExtensionDescription[] = []; + + for await (const extensions of this._resolveExtensions()) { + if (extensions instanceof ResolverExtensions) { + resolverExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, extensions.extensions, false); + this._registry.deltaExtensions(lock, resolverExtensions, []); + this._doHandleExtensionPoints(resolverExtensions, true); + } + if (extensions instanceof LocalExtensions) { + localExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, extensions.extensions, false); + } + if (extensions instanceof RemoteExtensions) { + remoteExtensions = checkEnabledAndProposedAPI(this._logService, this._extensionEnablementService, this._extensionsProposedApi, extensions.extensions, false); + } + } // `initializeRunningLocation` will look at the complete picture (e.g. an extension installed on both sides), // takes care of duplicates and picks a running location for each extension @@ -486,8 +504,8 @@ export abstract class AbstractExtensionService extends Disposable implements IEx this._startExtensionHostsIfNecessary(true, []); // Some remote extensions could run locally in the web worker, so store them - const remoteExtensionsThatNeedToRunLocally = (allowRemoteExtensionsInLocalWebWorker ? this._runningLocations.filterByExtensionHostKind(remoteExtensions, ExtensionHostKind.LocalWebWorker) : []); - const localProcessExtensions = (hasLocalProcess ? this._runningLocations.filterByExtensionHostKind(localExtensions, ExtensionHostKind.LocalProcess) : []); + const remoteExtensionsThatNeedToRunLocally = (this._allowRemoteExtensionsInLocalWebWorker ? this._runningLocations.filterByExtensionHostKind(remoteExtensions, ExtensionHostKind.LocalWebWorker) : []); + const localProcessExtensions = (this._hasLocalProcess ? this._runningLocations.filterByExtensionHostKind(localExtensions, ExtensionHostKind.LocalProcess) : []); const localWebWorkerExtensions = this._runningLocations.filterByExtensionHostKind(localExtensions, ExtensionHostKind.LocalWebWorker); remoteExtensions = this._runningLocations.filterByExtensionHostKind(remoteExtensions, ExtensionHostKind.Remote); @@ -499,8 +517,22 @@ export abstract class AbstractExtensionService extends Disposable implements IEx } const allExtensions = remoteExtensions.concat(localProcessExtensions).concat(localWebWorkerExtensions); + let toAdd = allExtensions; - const result = this._registry.deltaExtensions(lock, allExtensions, []); + if (resolverExtensions.length) { + // Add extensions that are not registered as resolvers but are in the final resolved set + toAdd = allExtensions.filter(extension => !resolverExtensions.some(e => ExtensionIdentifier.equals(e.identifier, extension.identifier) && e.extensionLocation.toString() === extension.extensionLocation.toString())); + // Remove extensions that are registered as resolvers but are not in the final resolved set + if (allExtensions.length < toAdd.length + resolverExtensions.length) { + const toRemove = resolverExtensions.filter(registered => !allExtensions.some(e => ExtensionIdentifier.equals(e.identifier, registered.identifier) && e.extensionLocation.toString() === registered.extensionLocation.toString())); + if (toRemove.length) { + this._registry.deltaExtensions(lock, [], toRemove.map(e => e.identifier)); + this._doHandleExtensionPoints(toRemove, true); + } + } + } + + const result = this._registry.deltaExtensions(lock, toAdd, []); if (result.removedDueToLooping.length > 0) { this._notificationService.notify({ severity: Severity.Error, @@ -508,7 +540,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx }); } - this._doHandleExtensionPoints(this._registry.getAllExtensionDescriptions()); + this._doHandleExtensionPoints(this._registry.getAllExtensionDescriptions(), false); } private async _handleExtensionTests(): Promise { @@ -1031,7 +1063,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx } } - private _doHandleExtensionPoints(affectedExtensions: IExtensionDescription[]): void { + private _doHandleExtensionPoints(affectedExtensions: IExtensionDescription[], onlyResolverExtensionPoints: boolean): void { const affectedExtensionPoints: { [extPointName: string]: boolean } = Object.create(null); for (const extensionDescription of affectedExtensions) { if (extensionDescription.contributes) { @@ -1046,15 +1078,15 @@ export abstract class AbstractExtensionService extends Disposable implements IEx const messageHandler = (msg: IMessage) => this._handleExtensionPointMessage(msg); const availableExtensions = this._registry.getAllExtensionDescriptions(); const extensionPoints = ExtensionsRegistry.getExtensionPoints(); - perf.mark('code/willHandleExtensionPoints'); + perf.mark(onlyResolverExtensionPoints ? 'code/willHandleResolverExtensionPoints' : 'code/willHandleExtensionPoints'); for (const extensionPoint of extensionPoints) { - if (affectedExtensionPoints[extensionPoint.name]) { + if (affectedExtensionPoints[extensionPoint.name] && (!onlyResolverExtensionPoints || extensionPoint.canHandleResolver)) { perf.mark(`code/willHandleExtensionPoint/${extensionPoint.name}`); AbstractExtensionService._handleExtensionPoint(extensionPoint, availableExtensions, messageHandler); perf.mark(`code/didHandleExtensionPoint/${extensionPoint.name}`); } } - perf.mark('code/didHandleExtensionPoints'); + perf.mark(onlyResolverExtensionPoints ? 'code/didHandleResolverExtensionPoints' : 'code/didHandleExtensionPoints'); } private _getOrCreateExtensionStatus(extensionId: ExtensionIdentifier): ExtensionStatus { @@ -1192,7 +1224,7 @@ export abstract class AbstractExtensionService extends Disposable implements IEx //#endregion - protected abstract _resolveExtensions(): Promise; + protected abstract _resolveExtensions(): AsyncIterable; protected abstract _onExtensionHostExit(code: number): Promise; protected abstract _resolveAuthority(remoteAuthority: string): Promise; } @@ -1280,15 +1312,26 @@ class ExtensionHostManagerData { } } -export class ResolvedExtensions { +export class ResolverExtensions { constructor( - public readonly local: IExtensionDescription[], - public readonly remote: IExtensionDescription[], - public readonly hasLocalProcess: boolean, - public readonly allowRemoteExtensionsInLocalWebWorker: boolean + public readonly extensions: IExtensionDescription[], ) { } } +export class LocalExtensions { + constructor( + public readonly extensions: IExtensionDescription[], + ) { } +} + +export class RemoteExtensions { + constructor( + public readonly extensions: IExtensionDescription[], + ) { } +} + +export type ResolvedExtensions = ResolverExtensions | LocalExtensions | RemoteExtensions; + export interface IExtensionHostFactory { createExtensionHost(runningLocations: ExtensionRunningLocationTracker, runningLocation: ExtensionRunningLocation, isInitialStart: boolean): IExtensionHost | null; } @@ -1300,6 +1343,10 @@ class DeltaExtensionsQueueItem { ) { } } +export function isResolverExtension(extension: IExtensionDescription): boolean { + return !!extension.activationEvents?.some(activationEvent => activationEvent.startsWith('onResolveRemoteAuthority:')); +} + /** * @argument extensions The extensions to be checked. * @argument ignoreWorkspaceTrust Do not take workspace trust into account. diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index 03fbc7de836..2c9636f6687 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -70,6 +70,7 @@ export interface IExtensionPoint { readonly name: string; setHandler(handler: IExtensionPointHandler): IDisposable; readonly defaultExtensionKind: ExtensionKind[] | undefined; + readonly canHandleResolver?: boolean; } export class ExtensionPointUserDelta { @@ -109,14 +110,16 @@ export class ExtensionPoint implements IExtensionPoint { public readonly name: string; public readonly defaultExtensionKind: ExtensionKind[] | undefined; + public readonly canHandleResolver?: boolean; private _handler: IExtensionPointHandler | null; private _users: IExtensionPointUser[] | null; private _delta: ExtensionPointUserDelta | null; - constructor(name: string, defaultExtensionKind: ExtensionKind[] | undefined) { + constructor(name: string, defaultExtensionKind: ExtensionKind[] | undefined, canHandleResolver?: boolean) { this.name = name; this.defaultExtensionKind = defaultExtensionKind; + this.canHandleResolver = canHandleResolver; this._handler = null; this._users = null; this._delta = null; @@ -608,6 +611,7 @@ export interface IExtensionPointDescriptor { deps?: IExtensionPoint[]; jsonSchema: IJSONSchema; defaultExtensionKind?: ExtensionKind[]; + canHandleResolver?: boolean; /** * A function which runs before the extension point has been validated and which * should collect automatic activation events from the contribution. @@ -623,7 +627,7 @@ export class ExtensionsRegistryImpl { if (this._extensionPoints.has(desc.extensionPoint)) { throw new Error('Duplicate extension point: ' + desc.extensionPoint); } - const result = new ExtensionPoint(desc.extensionPoint, desc.defaultExtensionKind); + const result = new ExtensionPoint(desc.extensionPoint, desc.defaultExtensionKind, desc.canHandleResolver); this._extensionPoints.set(desc.extensionPoint, result); if (desc.activationEventsGenerator) { ImplicitActivationEvents.register(desc.extensionPoint, desc.activationEventsGenerator); diff --git a/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts b/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts index 551ea511448..8adc7c19fe7 100644 --- a/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts +++ b/src/vs/workbench/services/extensions/electron-sandbox/nativeExtensionService.ts @@ -40,7 +40,7 @@ import { IWorkspaceTrustManagementService } from '../../../../platform/workspace import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { EnablementState, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from '../../extensionManagement/common/extensionManagement.js'; import { IWebWorkerExtensionHostDataProvider, IWebWorkerExtensionHostInitData, WebWorkerExtensionHost } from '../browser/webWorkerExtensionHost.js'; -import { AbstractExtensionService, ExtensionHostCrashTracker, IExtensionHostFactory, ResolvedExtensions, checkEnabledAndProposedAPI, extensionIsEnabled } from '../common/abstractExtensionService.js'; +import { AbstractExtensionService, ExtensionHostCrashTracker, IExtensionHostFactory, LocalExtensions, RemoteExtensions, ResolvedExtensions, ResolverExtensions, checkEnabledAndProposedAPI, extensionIsEnabled, isResolverExtension } from '../common/abstractExtensionService.js'; import { ExtensionDescriptionRegistrySnapshot } from '../common/extensionDescriptionRegistry.js'; import { parseExtensionDevOptions } from '../common/extensionDevOptions.js'; import { ExtensionHostKind, ExtensionRunningPreference, IExtensionHostKindPicker, extensionHostKindToString, extensionRunningPreferenceToString } from '../common/extensionHostKind.js'; @@ -58,6 +58,7 @@ import { IHostService } from '../../host/browser/host.js'; import { ILifecycleService, LifecyclePhase } from '../../lifecycle/common/lifecycle.js'; import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { IRemoteExplorerService } from '../../remote/common/remoteExplorerService.js'; +import { AsyncIterableEmitter, AsyncIterableObject } from '../../../../base/common/async.js'; export class NativeExtensionService extends AbstractExtensionService implements IExtensionService { @@ -103,6 +104,7 @@ export class NativeExtensionService extends AbstractExtensionService implements logService ); super( + { hasLocalProcess: true, allowRemoteExtensionsInLocalWebWorker: false }, extensionsProposedApi, extensionHostFactory, new NativeExtensionHostKindPicker(environmentService, configurationService, logService), @@ -316,7 +318,11 @@ export class NativeExtensionService extends AbstractExtensionService implements throw new Error(`Cannot get canonical URI because no extension is installed to resolve ${getRemoteAuthorityPrefix(remoteAuthority)}`); } - protected async _resolveExtensions(): Promise { + protected _resolveExtensions(): AsyncIterable { + return new AsyncIterableObject(emitter => this._doResolveExtensions(emitter)); + } + + private async _doResolveExtensions(emitter: AsyncIterableEmitter): Promise { this._extensionScanner.startScanningExtensions(); const remoteAuthority = this._environmentService.remoteAuthority; @@ -358,6 +364,12 @@ export class NativeExtensionService extends AbstractExtensionService implements this._logService.info(`Finished waiting on IWorkspaceTrustManagementService.workspaceResolved.`); } + const localExtensions = await this._scanAllLocalExtensions(); + const resolverExtensions = localExtensions.filter(extension => isResolverExtension(extension)); + if (resolverExtensions.length) { + emitter.emitOne(new ResolverExtensions(resolverExtensions)); + } + let resolverResult: ResolverResult; try { resolverResult = await this._resolveAuthorityInitial(remoteAuthority); @@ -372,7 +384,7 @@ export class NativeExtensionService extends AbstractExtensionService implements this._remoteAuthorityResolverService._setResolvedAuthorityError(remoteAuthority, err); // Proceed with the local extension host - return this._startLocalExtensionHost(); + return this._startLocalExtensionHost(emitter); } // set the resolved authority @@ -399,7 +411,7 @@ export class NativeExtensionService extends AbstractExtensionService implements if (!remoteEnv) { this._notificationService.notify({ severity: Severity.Error, message: nls.localize('getEnvironmentFailure', "Could not fetch remote environment") }); // Proceed with the local extension host - return this._startLocalExtensionHost(); + return this._startLocalExtensionHost(emitter); } updateProxyConfigurationsScope(remoteEnv.useHostProxy ? ConfigurationScope.APPLICATION : ConfigurationScope.MACHINE); @@ -409,15 +421,19 @@ export class NativeExtensionService extends AbstractExtensionService implements } - return this._startLocalExtensionHost(remoteExtensions); + return this._startLocalExtensionHost(emitter, remoteExtensions); } - private async _startLocalExtensionHost(remoteExtensions: IExtensionDescription[] = []): Promise { + private async _startLocalExtensionHost(emitter: AsyncIterableEmitter, remoteExtensions: IExtensionDescription[] = []): Promise { // Ensure that the workspace trust state has been fully initialized so // that the extension host can start with the correct set of extensions. await this._workspaceTrustManagementService.workspaceTrustInitialized; - return new ResolvedExtensions(await this._scanAllLocalExtensions(), remoteExtensions, /*hasLocalProcess*/true, /*allowRemoteExtensionsInLocalWebWorker*/false); + if (remoteExtensions.length) { + emitter.emitOne(new RemoteExtensions(remoteExtensions)); + } + + emitter.emitOne(new LocalExtensions(await this._scanAllLocalExtensions())); } protected async _onExtensionHostExit(code: number): Promise { diff --git a/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts b/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts index 5e94ff70590..89b97f4ee7c 100644 --- a/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts +++ b/src/vs/workbench/services/extensions/test/browser/extensionService.test.ts @@ -163,6 +163,7 @@ suite('ExtensionService', () => { } }; super( + { allowRemoteExtensionsInLocalWebWorker: false, hasLocalProcess: true }, extensionsProposedApi, extensionHostFactory, null!, @@ -209,7 +210,7 @@ suite('ExtensionService', () => { } }; } - protected _resolveExtensions(): Promise { + protected _resolveExtensions(): AsyncIterable { throw new Error('Method not implemented.'); } protected _scanSingleExtension(extension: IExtension): Promise { diff --git a/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts b/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts index 6f7f0f20cd7..ddfd1f0553f 100644 --- a/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts +++ b/src/vs/workbench/services/userDataSync/browser/userDataSyncWorkbenchService.ts @@ -75,7 +75,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat private _authenticationProviders: IAuthenticationProvider[] = []; get authenticationProviders() { return this._authenticationProviders; } - private _accountStatus: AccountStatus = AccountStatus.Unavailable; + private _accountStatus: AccountStatus = AccountStatus.Uninitialized; get accountStatus(): AccountStatus { return this._accountStatus; } private readonly _onDidChangeAccountStatus = this._register(new Emitter()); readonly onDidChangeAccountStatus = this._onDidChangeAccountStatus.event; @@ -144,10 +144,9 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private updateAuthenticationProviders(): boolean { - this.logService.info('Settings Sync: Updating authentication providers. Authentication Providers from store:', this.userDataSyncStoreManagementService.userDataSyncStore?.authenticationProviders || [].map(({ id }) => id)); const oldValue = this._authenticationProviders; this._authenticationProviders = (this.userDataSyncStoreManagementService.userDataSyncStore?.authenticationProviders || []).filter(({ id }) => this.authenticationService.declaredProviders.some(provider => provider.id === id)); - this.logService.info('Settings Sync: Authentication providers updated', this._authenticationProviders.map(({ id }) => id)); + this.logService.trace('Settings Sync: Authentication providers updated', this._authenticationProviders.map(({ id }) => id)); return equals(oldValue, this._authenticationProviders, (a, b) => a.id === b.id); } @@ -189,6 +188,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat const initPromise = this.update('initialize'); this._register(this.authenticationService.onDidChangeDeclaredProviders(() => { if (this.updateAuthenticationProviders()) { + // Trigger update only after the initialization is done initPromise.finally(() => this.update('declared authentication providers changed')); } })); @@ -223,7 +223,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private async update(reason: string): Promise { - this.logService.info(`Settings Sync: Updating due to ${reason}`); + this.logService.trace(`Settings Sync: Updating due to ${reason}`); this.updateAuthenticationProviders(); await this.updateCurrentAccount(); @@ -237,18 +237,17 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private async updateCurrentAccount(): Promise { - this.logService.info('Settings Sync: Updating the current account'); + this.logService.trace('Settings Sync: Updating the current account'); const currentSessionId = this.currentSessionId; const currentAuthenticationProviderId = this.currentAuthenticationProviderId; if (currentSessionId) { const authenticationProviders = currentAuthenticationProviderId ? this.authenticationProviders.filter(({ id }) => id === currentAuthenticationProviderId) : this.authenticationProviders; - this.logService.info('Settings Sync: Updating the current account using current session', currentSessionId, currentAuthenticationProviderId, authenticationProviders.map(({ id }) => id)); for (const { id, scopes } of authenticationProviders) { const sessions = (await this.authenticationService.getSessions(id, scopes)) || []; for (const session of sessions) { if (session.id === currentSessionId) { this._current = new UserDataSyncAccount(id, session); - this.logService.info('Settings Sync: Updated the current account', this._current.accountName); + this.logService.trace('Settings Sync: Updated the current account', this._current.accountName); return; } } @@ -261,7 +260,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat let value: { token: string; authenticationProviderId: string } | undefined = undefined; if (current) { try { - this.logService.info('Settings Sync: Updating the token for the account', current.accountName); + this.logService.trace('Settings Sync: Updating the token for the account', current.accountName); const token = current.token; this.logService.info('Settings Sync: Token updated for the account', current.accountName); value = { token, authenticationProviderId: current.authenticationProviderId }; @@ -273,7 +272,7 @@ export class UserDataSyncWorkbenchService extends Disposable implements IUserDat } private updateAccountStatus(accountStatus: AccountStatus): void { - this.logService.info(`Settings Sync: Updating the account status to ${accountStatus}`); + this.logService.trace(`Settings Sync: Updating the account status to ${accountStatus}`); if (this._accountStatus !== accountStatus) { const previous = this._accountStatus; this.logService.info(`Settings Sync: Account status changed from ${previous} to ${accountStatus}`); diff --git a/src/vs/workbench/services/userDataSync/common/userDataSync.ts b/src/vs/workbench/services/userDataSync/common/userDataSync.ts index d0b966f4d0f..82baf1451c2 100644 --- a/src/vs/workbench/services/userDataSync/common/userDataSync.ts +++ b/src/vs/workbench/services/userDataSync/common/userDataSync.ts @@ -83,7 +83,7 @@ export const SYNC_VIEW_ICON = registerIcon('settings-sync-view-icon', Codicon.sy // Contexts export const CONTEXT_SYNC_STATE = new RawContextKey('syncStatus', SyncStatus.Uninitialized); export const CONTEXT_SYNC_ENABLEMENT = new RawContextKey('syncEnabled', false); -export const CONTEXT_ACCOUNT_STATE = new RawContextKey('userDataSyncAccountStatus', AccountStatus.Unavailable); +export const CONTEXT_ACCOUNT_STATE = new RawContextKey('userDataSyncAccountStatus', AccountStatus.Uninitialized); export const CONTEXT_ENABLE_ACTIVITY_VIEWS = new RawContextKey(`enableSyncActivityViews`, false); export const CONTEXT_ENABLE_SYNC_CONFLICTS_VIEW = new RawContextKey(`enableSyncConflictsView`, false); export const CONTEXT_HAS_CONFLICTS = new RawContextKey('hasConflicts', false); diff --git a/src/vscode-dts/vscode.proposed.lmTools.d.ts b/src/vscode-dts/vscode.proposed.lmTools.d.ts deleted file mode 100644 index 63084e91433..00000000000 --- a/src/vscode-dts/vscode.proposed.lmTools.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// version: 15 - -declare module 'vscode' { -} diff --git a/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts b/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts new file mode 100644 index 00000000000..b6a3bf18684 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // @CrafterKolyan https://github.com/microsoft/vscode/issues/233274 + + export interface QuickPick { + /** + * Selection range in the input value. Defined as tuple of two number where the + * first is the inclusive start index and the second the exclusive end index. When + * `undefined` the whole pre-filled value will be selected, when empty (start equals end) + * only the cursor will be set, otherwise the defined range will be selected. + * + * This property does not get updated when the user types or makes a selection, + * but it can be updated by the extension. + */ + valueSelection: readonly [number, number] | undefined; + } +}