mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-23 01:58:53 +01:00
341 lines
12 KiB
TypeScript
341 lines
12 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 { 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;
|
|
}
|
|
|
|
}
|