/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { addDisposableListener, Dimension } from '../../../../base/browser/dom.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { assertType } from '../../../../base/common/types.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { StableEditorBottomScrollState } from '../../../../editor/browser/stableEditorScroll.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { ScrollType } from '../../../../editor/common/editorCommon.js'; import { IOptions, ZoneWidget } from '../../../../editor/contrib/zoneWidget/browser/zoneWidget.js'; import { localize } from '../../../../nls.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatWidgetViewOptions } from '../../chat/browser/chat.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; import { isResponseVM } from '../../chat/common/chatViewModel.js'; import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; export class InlineChatZoneWidget extends ZoneWidget { private static readonly _options: IOptions = { showFrame: true, frameWidth: 1, // frameColor: 'var(--vscode-inlineChat-border)', isResizeable: true, showArrow: false, isAccessible: true, className: 'inline-chat-widget', keepEditorSelection: true, showInHiddenAreas: true, ordinal: 50000, }; readonly widget: EditorBasedInlineChatWidget; private readonly _scrollUp = this._disposables.add(new ScrollUpState(this.editor)); private readonly _ctxCursorPosition: IContextKey<'above' | 'below' | ''>; private _dimension?: Dimension; constructor( location: IChatWidgetLocationOptions, options: IChatWidgetViewOptions | undefined, editor: ICodeEditor, @IInstantiationService private readonly _instaService: IInstantiationService, @ILogService private _logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, ) { super(editor, InlineChatZoneWidget._options); this._ctxCursorPosition = CTX_INLINE_CHAT_OUTER_CURSOR_POSITION.bindTo(contextKeyService); this._disposables.add(toDisposable(() => { this._ctxCursorPosition.reset(); })); this.widget = this._instaService.createInstance(EditorBasedInlineChatWidget, location, this.editor, { statusMenuId: { menu: MENU_INLINE_CHAT_WIDGET_STATUS, options: { buttonConfigProvider: (action, index) => { const isSecondary = index > 0; if (new Set([ACTION_REGENERATE_RESPONSE, ACTION_TOGGLE_DIFF, ACTION_REPORT_ISSUE]).has(action.id)) { return { isSecondary, showIcon: true, showLabel: false }; } else { return { isSecondary }; } } } }, secondaryMenuId: MENU_INLINE_CHAT_WIDGET_SECONDARY, inZoneWidget: true, chatWidgetViewOptions: { menus: { telemetrySource: 'interactiveEditorWidget-toolbar', }, ...options, rendererOptions: { renderTextEditsAsSummary: (uri) => { // render when dealing with the current file in the editor return isEqual(uri, editor.getModel()?.uri); }, renderDetectedCommandsWithRequest: true, ...options?.rendererOptions }, } }); this._disposables.add(this.widget); let revealFn: (() => void) | undefined; this._disposables.add(this.widget.chatWidget.onWillMaybeChangeHeight(() => { if (this.position) { revealFn = this._createZoneAndScrollRestoreFn(this.position); } })); this._disposables.add(this.widget.onDidChangeHeight(() => { if (this.position && !this._usesResizeHeight) { // only relayout when visible revealFn ??= this._createZoneAndScrollRestoreFn(this.position); const height = this._computeHeight(); this._relayout(height.linesValue); revealFn?.(); revealFn = undefined; } })); this.create(); this._disposables.add(autorun(r => { const isBusy = this.widget.requestInProgress.read(r); this.domNode.firstElementChild?.classList.toggle('busy', isBusy); })); this._disposables.add(addDisposableListener(this.domNode, 'click', e => { if (!this.editor.hasWidgetFocus() && !this.widget.hasFocus()) { this.editor.focus(); } }, true)); // todo@jrieken listen ONLY when showing const updateCursorIsAboveContextKey = () => { if (!this.position || !this.editor.hasModel()) { this._ctxCursorPosition.reset(); } else if (this.position.lineNumber === this.editor.getPosition().lineNumber) { this._ctxCursorPosition.set('above'); } else if (this.position.lineNumber + 1 === this.editor.getPosition().lineNumber) { this._ctxCursorPosition.set('below'); } else { this._ctxCursorPosition.reset(); } }; this._disposables.add(this.editor.onDidChangeCursorPosition(e => updateCursorIsAboveContextKey())); this._disposables.add(this.editor.onDidFocusEditorText(e => updateCursorIsAboveContextKey())); updateCursorIsAboveContextKey(); } protected override _fillContainer(container: HTMLElement): void { container.style.setProperty('--vscode-inlineChat-background', 'var(--vscode-editor-background)'); container.appendChild(this.widget.domNode); } protected override _doLayout(heightInPixel: number): void { this._updatePadding(); const info = this.editor.getLayoutInfo(); const width = info.contentWidth - info.verticalScrollbarWidth; // width = Math.min(850, width); this._dimension = new Dimension(width, heightInPixel); this.widget.layout(this._dimension); } private _computeHeight(): { linesValue: number; pixelsValue: number } { const chatContentHeight = this.widget.contentHeight; const editorHeight = this.editor.getLayoutInfo().height; const contentHeight = this._decoratingElementsHeight() + Math.min(chatContentHeight, Math.max(this.widget.minHeight, editorHeight * 0.42)); const heightInLines = contentHeight / this.editor.getOption(EditorOption.lineHeight); return { linesValue: heightInLines, pixelsValue: contentHeight }; } protected override _getResizeBounds(): { minLines: number; maxLines: number } { const lineHeight = this.editor.getOption(EditorOption.lineHeight); const decoHeight = this._decoratingElementsHeight(); const minHeightPx = decoHeight + this.widget.minHeight; const maxHeightPx = decoHeight + this.widget.contentHeight; return { minLines: minHeightPx / lineHeight, maxLines: maxHeightPx / lineHeight }; } protected override _onWidth(_widthInPixel: number): void { if (this._dimension) { this._doLayout(this._dimension.height); } } override show(position: Position): void { assertType(this.container); this._updatePadding(); const revealZone = this._createZoneAndScrollRestoreFn(position); super.show(position, this._computeHeight().linesValue); this.widget.chatWidget.setVisible(true); this.widget.focus(); revealZone(); this._scrollUp.enable(); } private _updatePadding() { assertType(this.container); const info = this.editor.getLayoutInfo(); const marginWithoutIndentation = info.glyphMarginWidth + info.lineNumbersWidth + info.decorationsWidth; this.container.style.paddingLeft = `${marginWithoutIndentation}px`; } reveal(position: Position) { const stickyScroll = this.editor.getOption(EditorOption.stickyScroll); const magicValue = stickyScroll.enabled ? stickyScroll.maxLineCount : 0; this.editor.revealLines(position.lineNumber + magicValue, position.lineNumber + magicValue, ScrollType.Immediate); this._scrollUp.reset(); this.updatePositionAndHeight(position); } override updatePositionAndHeight(position: Position): void { const revealZone = this._createZoneAndScrollRestoreFn(position); super.updatePositionAndHeight(position, !this._usesResizeHeight ? this._computeHeight().linesValue : undefined); revealZone(); } private _createZoneAndScrollRestoreFn(position: Position): () => void { const scrollState = StableEditorBottomScrollState.capture(this.editor); const lineNumber = position.lineNumber <= 1 ? 1 : 1 + position.lineNumber; const scrollTop = this.editor.getScrollTop(); const lineTop = this.editor.getTopForLineNumber(lineNumber); const zoneTop = lineTop - this._computeHeight().pixelsValue; const hasResponse = this.widget.chatWidget.viewModel?.getItems().find(candidate => { return isResponseVM(candidate) && candidate.response.value.length > 0; }); if (hasResponse && zoneTop < scrollTop || this._scrollUp.didScrollUpOrDown) { // don't reveal the zone if it is already out of view (unless we are still getting ready) // or if an outside scroll-up happened (e.g the user scrolled up/down to see the new content) return this._scrollUp.runIgnored(() => { scrollState.restore(this.editor); }); } return this._scrollUp.runIgnored(() => { scrollState.restore(this.editor); const scrollTop = this.editor.getScrollTop(); const lineTop = this.editor.getTopForLineNumber(lineNumber); const zoneTop = lineTop - this._computeHeight().pixelsValue; const editorHeight = this.editor.getLayoutInfo().height; const lineBottom = this.editor.getBottomForLineNumber(lineNumber); let newScrollTop = zoneTop; let forceScrollTop = false; if (lineBottom >= (scrollTop + editorHeight)) { // revealing the top of the zone would push out the line we are interested in and // therefore we keep the line in the viewport newScrollTop = lineBottom - editorHeight; forceScrollTop = true; } if (newScrollTop < scrollTop || forceScrollTop) { this._logService.trace('[IE] REVEAL zone', { zoneTop, lineTop, lineBottom, scrollTop, newScrollTop, forceScrollTop }); this.editor.setScrollTop(newScrollTop, ScrollType.Immediate); } }); } protected override revealRange(range: Range, isLastLine: boolean): void { // noop } override hide(): void { const scrollState = StableEditorBottomScrollState.capture(this.editor); this._scrollUp.disable(); this._ctxCursorPosition.reset(); this.widget.reset(); this.widget.chatWidget.setVisible(false); super.hide(); aria.status(localize('inlineChatClosed', 'Closed inline chat widget')); scrollState.restore(this.editor); } } class ScrollUpState { private _didScrollUpOrDown?: boolean; private _ignoreEvents = false; private readonly _listener = new MutableDisposable(); constructor(private readonly _editor: ICodeEditor) { } dispose(): void { this._listener.dispose(); } reset(): void { this._didScrollUpOrDown = undefined; } enable(): void { this._didScrollUpOrDown = undefined; this._listener.value = this._editor.onDidScrollChange(e => { if (!e.scrollTopChanged || this._ignoreEvents) { return; } this._listener.clear(); this._didScrollUpOrDown = true; }); } disable(): void { this._listener.clear(); this._didScrollUpOrDown = undefined; } runIgnored(callback: () => void): () => void { return () => { this._ignoreEvents = true; try { return callback(); } finally { this._ignoreEvents = false; } }; } get didScrollUpOrDown(): boolean | undefined { return this._didScrollUpOrDown; } }