Files
vscode/src/vs/workbench/contrib/chat/browser/codeBlockPart.ts
T

416 lines
17 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./codeBlockPart';
import * as dom from 'vs/base/browser/dom';
import { Button } from 'vs/base/browser/ui/button/button';
import { Codicon } from 'vs/base/common/codicons';
import { Emitter } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import { Schemas } from 'vs/base/common/network';
import { URI, UriComponents } from 'vs/base/common/uri';
import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration';
import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget';
import { EDITOR_FONT_DEFAULTS, EditorOption, IEditorOptions } from 'vs/editor/common/config/editorOptions';
import { IRange, Range } from 'vs/editor/common/core/range';
import { ScrollType } from 'vs/editor/common/editorCommon';
import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model';
import { IModelService } from 'vs/editor/common/services/model';
import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService';
import { BracketMatchingController } from 'vs/editor/contrib/bracketMatching/browser/bracketMatching';
import { ContextMenuController } from 'vs/editor/contrib/contextmenu/browser/contextmenu';
import { GotoDefinitionAtPositionEditorContribution } from 'vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition';
import { HoverController } from 'vs/editor/contrib/hover/browser/hover';
import { ViewportSemanticTokensContribution } from 'vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens';
import { SmartSelectController } from 'vs/editor/contrib/smartSelect/browser/smartSelect';
import { WordHighlighterContribution } from 'vs/editor/contrib/wordHighlighter/browser/wordHighlighter';
import { localize } from 'vs/nls';
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar';
import { MenuId } from 'vs/platform/actions/common/actions';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration';
import { IChatRendererDelegate } from 'vs/workbench/contrib/chat/browser/chatListRenderer';
import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions';
import { IChatResponseViewModel, isResponseVM } from 'vs/workbench/contrib/chat/common/chatViewModel';
import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer';
import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard';
import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions';
import { IMarkdownVulnerability } from '../common/annotations';
import { TabFocus } from 'vs/editor/browser/config/tabFocus';
const $ = dom.$;
export interface ICodeBlockData {
readonly codeBlockIndex: number;
readonly element: unknown;
readonly textModel: Promise<IResolvedTextEditorModel>;
readonly languageId: string;
readonly vulns?: readonly IMarkdownVulnerability[];
readonly range?: Range;
readonly parentContextKeyService?: IContextKeyService;
readonly hideToolbar?: boolean;
}
/**
* Special markdown code block language id used to render a local file.
*
* The text of the code path should be a {@link LocalFileCodeBlockData} json object.
*/
export const localFileLanguageId = 'vscode-local-file';
export function parseLocalFileData(text: string) {
interface RawLocalFileCodeBlockData {
readonly uri: UriComponents;
readonly range?: IRange;
}
let data: RawLocalFileCodeBlockData;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error('Could not parse code block local file data');
}
let uri: URI;
try {
uri = URI.revive(data?.uri);
} catch (e) {
throw new Error('Invalid code block local file data URI');
}
let range: IRange | undefined;
if (data.range) {
// Note that since this is coming from extensions, position are actually zero based and must be converted.
range = new Range(data.range.startLineNumber + 1, data.range.startColumn + 1, data.range.endLineNumber + 1, data.range.endColumn + 1);
}
return { uri, range };
}
export interface ICodeBlockActionContext {
code: string;
languageId?: string;
codeBlockIndex: number;
element: unknown;
}
const defaultCodeblockPadding = 10;
export class CodeBlockPart extends Disposable {
protected readonly _onDidChangeContentHeight = this._register(new Emitter<void>());
public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event;
public readonly editor: CodeEditorWidget;
protected readonly toolbar: MenuWorkbenchToolBar;
private readonly contextKeyService: IContextKeyService;
public readonly element: HTMLElement;
private readonly vulnsButton: Button;
private readonly vulnsListElement: HTMLElement;
private currentCodeBlockData: ICodeBlockData | undefined;
private currentScrollWidth = 0;
constructor(
private readonly options: ChatEditorOptions,
readonly menuId: MenuId,
delegate: IChatRendererDelegate,
overflowWidgetsDomNode: HTMLElement | undefined,
@IInstantiationService instantiationService: IInstantiationService,
@IContextKeyService contextKeyService: IContextKeyService,
@IModelService protected readonly modelService: IModelService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IAccessibilityService private readonly accessibilityService: IAccessibilityService,
) {
super();
this.element = $('.interactive-result-code-block');
this.contextKeyService = this._register(contextKeyService.createScoped(this.element));
const scopedInstantiationService = instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService]));
const editorElement = dom.append(this.element, $('.interactive-result-editor'));
this.editor = this.createEditor(scopedInstantiationService, editorElement, {
...getSimpleEditorOptions(this.configurationService),
readOnly: true,
lineNumbers: 'off',
selectOnLineNumbers: true,
scrollBeyondLastLine: false,
lineDecorationsWidth: 8,
dragAndDrop: false,
padding: { top: defaultCodeblockPadding, bottom: defaultCodeblockPadding },
mouseWheelZoom: false,
scrollbar: {
vertical: 'hidden',
alwaysConsumeMouseWheel: false
},
definitionLinkOpensInPeek: false,
gotoLocation: {
multiple: 'goto',
multipleDeclarations: 'goto',
multipleDefinitions: 'goto',
multipleImplementations: 'goto',
},
ariaLabel: localize('chat.codeBlockHelp', 'Code block'),
overflowWidgetsDomNode,
...this.getEditorOptionsFromConfig(),
});
const toolbarElement = dom.append(this.element, $('.interactive-result-code-block-toolbar'));
const editorScopedService = this.editor.contextKeyService.createScoped(toolbarElement);
const editorScopedInstantiationService = scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService]));
this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarElement, menuId, {
menuOptions: {
shouldForwardArgs: true
}
}));
const vulnsContainer = dom.append(this.element, $('.interactive-result-vulns'));
const vulnsHeaderElement = dom.append(vulnsContainer, $('.interactive-result-vulns-header', undefined));
this.vulnsButton = this._register(new Button(vulnsHeaderElement, {
buttonBackground: undefined,
buttonBorder: undefined,
buttonForeground: undefined,
buttonHoverBackground: undefined,
buttonSecondaryBackground: undefined,
buttonSecondaryForeground: undefined,
buttonSecondaryHoverBackground: undefined,
buttonSeparator: undefined,
supportIcons: true
}));
this.vulnsListElement = dom.append(vulnsContainer, $('ul.interactive-result-vulns-list'));
this._register(this.vulnsButton.onDidClick(() => {
const element = this.currentCodeBlockData!.element as IChatResponseViewModel;
element.vulnerabilitiesListExpanded = !element.vulnerabilitiesListExpanded;
this.vulnsButton.label = this.getVulnerabilitiesLabel();
this.element.classList.toggle('chat-vulnerabilities-collapsed', !element.vulnerabilitiesListExpanded);
this._onDidChangeContentHeight.fire();
// this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded);
}));
this._register(this.toolbar.onDidChangeDropdownVisibility(e => {
toolbarElement.classList.toggle('force-visibility', e);
}));
this._configureForScreenReader();
this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this._configureForScreenReader()));
this._register(this.configurationService.onDidChangeConfiguration((e) => {
if (e.affectedKeys.has(AccessibilityVerbositySettingId.Chat)) {
this._configureForScreenReader();
}
}));
this._register(this.options.onDidChange(() => {
this.editor.updateOptions(this.getEditorOptionsFromConfig());
}));
this._register(this.editor.onDidScrollChange(e => {
this.currentScrollWidth = e.scrollWidth;
}));
this._register(this.editor.onDidContentSizeChange(e => {
if (e.contentHeightChanged) {
this._onDidChangeContentHeight.fire();
}
}));
this._register(this.editor.onDidBlurEditorWidget(() => {
this.element.classList.remove('focused');
WordHighlighterContribution.get(this.editor)?.stopHighlighting();
this.clearWidgets();
}));
this._register(this.editor.onDidFocusEditorWidget(() => {
this.element.classList.add('focused');
WordHighlighterContribution.get(this.editor)?.restoreViewState(true);
}));
// Parent list scrolled
if (delegate.onDidScroll) {
this._register(delegate.onDidScroll(e => {
this.clearWidgets();
}));
}
}
get uri(): URI | undefined {
return this.editor.getModel()?.uri;
}
private createEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly<IEditorConstructionOptions>): CodeEditorWidget {
return this._register(instantiationService.createInstance(CodeEditorWidget, parent, options, {
isSimpleWidget: false,
contributions: EditorExtensionsRegistry.getSomeEditorContributions([
MenuPreventer.ID,
SelectionClipboardContributionID,
ContextMenuController.ID,
WordHighlighterContribution.ID,
ViewportSemanticTokensContribution.ID,
BracketMatchingController.ID,
SmartSelectController.ID,
HoverController.ID,
GotoDefinitionAtPositionEditorContribution.ID,
])
}));
}
focus(): void {
this.editor.focus();
}
private updatePaddingForLayout() {
// scrollWidth = "the width of the content that needs to be scrolled"
// contentWidth = "the width of the area where content is displayed"
const horizontalScrollbarVisible = this.currentScrollWidth > this.editor.getLayoutInfo().contentWidth;
const scrollbarHeight = this.editor.getLayoutInfo().horizontalScrollbarHeight;
const bottomPadding = horizontalScrollbarVisible ?
Math.max(defaultCodeblockPadding - scrollbarHeight, 2) :
defaultCodeblockPadding;
this.editor.updateOptions({ padding: { top: defaultCodeblockPadding, bottom: bottomPadding } });
}
private _configureForScreenReader(): void {
const toolbarElt = this.toolbar.getElement();
if (this.accessibilityService.isScreenReaderOptimized()) {
toolbarElt.style.display = 'block';
toolbarElt.ariaLabel = this.configurationService.getValue(AccessibilityVerbositySettingId.Chat) ? localize('chat.codeBlock.toolbarVerbose', 'Toolbar for code block which can be reached via tab') : localize('chat.codeBlock.toolbar', 'Code block toolbar');
} else {
toolbarElt.style.display = '';
}
}
private getEditorOptionsFromConfig(): IEditorOptions {
return {
wordWrap: this.options.configuration.resultEditor.wordWrap,
fontLigatures: this.options.configuration.resultEditor.fontLigatures,
bracketPairColorization: this.options.configuration.resultEditor.bracketPairColorization,
fontFamily: this.options.configuration.resultEditor.fontFamily === 'default' ?
EDITOR_FONT_DEFAULTS.fontFamily :
this.options.configuration.resultEditor.fontFamily,
fontSize: this.options.configuration.resultEditor.fontSize,
fontWeight: this.options.configuration.resultEditor.fontWeight,
lineHeight: this.options.configuration.resultEditor.lineHeight,
};
}
layout(width: number): void {
const contentHeight = this.getContentHeight();
const editorBorder = 2;
this.editor.layout({ width: width - editorBorder, height: contentHeight });
this.updatePaddingForLayout();
}
private getContentHeight() {
if (this.currentCodeBlockData?.range) {
const lineCount = this.currentCodeBlockData.range.endLineNumber - this.currentCodeBlockData.range.startLineNumber + 1;
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
return lineCount * lineHeight;
}
return this.editor.getContentHeight();
}
async render(data: ICodeBlockData, width: number, editable: boolean | undefined) {
this.currentCodeBlockData = data;
if (data.parentContextKeyService) {
this.contextKeyService.updateParent(data.parentContextKeyService);
}
if (this.options.configuration.resultEditor.wordWrap === 'on') {
// Initialize the editor with the new proper width so that getContentHeight
// will be computed correctly in the next call to layout()
this.layout(width);
}
await this.updateEditor(data);
this.layout(width);
if (editable) {
this._register(this.editor.onDidFocusEditorWidget(() => TabFocus.setTabFocusMode(true)));
this._register(this.editor.onDidBlurEditorWidget(() => TabFocus.setTabFocusMode(false)));
}
this.editor.updateOptions({ ariaLabel: localize('chat.codeBlockLabel', "Code block {0}", data.codeBlockIndex + 1), readOnly: !editable });
if (data.hideToolbar) {
dom.hide(this.toolbar.getElement());
} else {
dom.show(this.toolbar.getElement());
}
if (data.vulns?.length && isResponseVM(data.element)) {
dom.clearNode(this.vulnsListElement);
this.element.classList.remove('no-vulns');
this.element.classList.toggle('chat-vulnerabilities-collapsed', !data.element.vulnerabilitiesListExpanded);
dom.append(this.vulnsListElement, ...data.vulns.map(v => $('li', undefined, $('span.chat-vuln-title', undefined, v.title), ' ' + v.description)));
this.vulnsButton.label = this.getVulnerabilitiesLabel();
} else {
this.element.classList.add('no-vulns');
}
}
reset() {
this.clearWidgets();
}
private clearWidgets() {
HoverController.get(this.editor)?.hideContentHover();
}
private async updateEditor(data: ICodeBlockData): Promise<void> {
const textModel = (await data.textModel).textEditorModel;
this.editor.setModel(textModel);
if (data.range) {
this.editor.setSelection(data.range);
this.editor.revealRangeInCenter(data.range, ScrollType.Immediate);
}
this.toolbar.context = {
code: textModel.getTextBuffer().getValueInRange(data.range ?? textModel.getFullModelRange(), EndOfLinePreference.TextDefined),
codeBlockIndex: data.codeBlockIndex,
element: data.element,
languageId: textModel.getLanguageId()
} satisfies ICodeBlockActionContext;
}
private getVulnerabilitiesLabel(): string {
if (!this.currentCodeBlockData || !this.currentCodeBlockData.vulns) {
return '';
}
const referencesLabel = this.currentCodeBlockData.vulns.length > 1 ?
localize('vulnerabilitiesPlural', "{0} vulnerabilities", this.currentCodeBlockData.vulns.length) :
localize('vulnerabilitiesSingular', "{0} vulnerability", 1);
const icon = (element: IChatResponseViewModel) => element.vulnerabilitiesListExpanded ? Codicon.chevronDown : Codicon.chevronRight;
return `${referencesLabel} $(${icon(this.currentCodeBlockData.element as IChatResponseViewModel).id})`;
}
}
export class ChatCodeBlockContentProvider extends Disposable implements ITextModelContentProvider {
constructor(
@ITextModelService textModelService: ITextModelService,
@IModelService private readonly _modelService: IModelService,
) {
super();
this._register(textModelService.registerTextModelContentProvider(Schemas.vscodeChatCodeBlock, this));
}
async provideTextContent(resource: URI): Promise<ITextModel | null> {
const existing = this._modelService.getModel(resource);
if (existing) {
return existing;
}
return this._modelService.createModel('', null, resource);
}
}