Implement copy/vote action toolbars in interactive session (#176711)

This commit is contained in:
Rob Lourens
2023-03-09 18:04:40 -08:00
committed by GitHub
parent ef1542d820
commit b60db78000
5 changed files with 308 additions and 89 deletions
@@ -180,6 +180,8 @@ export class MenuId {
static readonly MergeInputResultToolbar = new MenuId('MergeToolbarResultToolbar');
static readonly InlineSuggestionToolbar = new MenuId('InlineSuggestionToolbar');
static readonly InteractiveSessionContext = new MenuId('InteractiveSessionContext');
static readonly InteractiveSessionCodeBlock = new MenuId('InteractiveSessionCodeblock');
static readonly InteractiveSessionTitle = new MenuId('InteractiveSessionTitle');
/**
* Create or reuse a `MenuId` with the given identifier
@@ -0,0 +1,70 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Codicon } from 'vs/base/common/codicons';
import { ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import { localize } from 'vs/nls';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { INTERACTIVE_SESSION_CATEGORY } from 'vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionActions';
export function registerInteractiveSessionCodeBlockActions() {
registerAction2(class CopyCodeBlockAction extends Action2 {
constructor() {
super({
id: 'workbench.action.interactiveSession.copyCodeBlock',
title: {
value: localize('interactive.copyCodeBlock.label', "Copy"),
original: 'Copy'
},
f1: false,
category: INTERACTIVE_SESSION_CATEGORY,
icon: Codicon.copy,
menu: {
id: MenuId.InteractiveSessionCodeBlock,
group: 'navigation',
}
});
}
run(accessor: ServicesAccessor, ...args: any[]) {
const code = args[0];
if (typeof code !== 'string') {
return;
}
const clipboardService = accessor.get(IClipboardService);
clipboardService.writeText(code);
}
});
// registerAction2(class InsertCodeBlockAction extends Action2 {
// constructor() {
// super({
// id: 'workbench.action.interactiveSession.insertCodeBlock',
// title: {
// value: localize('interactive.insertCodeBlock.label', "Insert at Cursor"),
// original: 'Insert at Cursor'
// },
// f1: false,
// category: INTERACTIVE_SESSION_CATEGORY,
// menu: {
// id: MenuId.InteractiveSessionCodeBlock,
// }
// });
// }
// run(accessor: ServicesAccessor, ...args: any[]) {
// const code = args[0];
// if (typeof code !== 'string') {
// return;
// }
// // TODO
// const clipboardService = accessor.get(IClipboardService);
// clipboardService.writeText(code);
// }
// });
}
@@ -29,6 +29,8 @@ import { IMarkdownRenderResult, MarkdownRenderer } from 'vs/editor/contrib/markd
import { ViewportSemanticTokensContribution } from 'vs/editor/contrib/semanticTokens/browser/viewportSemanticTokens';
import { SmartSelectController } from 'vs/editor/contrib/smartSelect/browser/smartSelect';
import { localize } from 'vs/nls';
import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar';
import { MenuId } from 'vs/platform/actions/common/actions';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
@@ -49,10 +51,11 @@ export type InteractiveTreeItem = IInteractiveRequestViewModel | IInteractiveRes
interface IInteractiveListItemTemplate {
rowContainer: HTMLElement;
header: HTMLElement;
titleToolbar: MenuWorkbenchToolBar;
avatar: HTMLElement;
username: HTMLElement;
value: HTMLElement;
templateDisposables: IDisposable;
elementDisposables: DisposableStore;
}
@@ -137,15 +140,22 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
}
renderTemplate(container: HTMLElement): IInteractiveListItemTemplate {
const templateDisposables = new DisposableStore();
const rowContainer = dom.append(container, $('.interactive-item-container'));
const header = dom.append(rowContainer, $('.header'));
const avatar = dom.append(header, $('.avatar'));
const username = document.createElement('h3');
header.appendChild(username);
const user = dom.append(header, $('.user'));
const avatar = dom.append(user, $('.avatar'));
const username = dom.append(user, $('h3.username'));
const value = dom.append(rowContainer, $('.value'));
const elementDisposables = new DisposableStore();
const template: IInteractiveListItemTemplate = { header, avatar, username, value, rowContainer, elementDisposables };
const titleToolbar = templateDisposables.add(this.instantiationService.createInstance(MenuWorkbenchToolBar, header, MenuId.InteractiveSessionTitle, {
menuOptions: {
shouldForwardArgs: true
}
}));
const template: IInteractiveListItemTemplate = { avatar, username, value, rowContainer, elementDisposables, titleToolbar, templateDisposables };
return template;
}
@@ -154,6 +164,8 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
const kind = isRequestVM(element) ? 'request' : 'response';
this.traceLayout('renderElement', `${kind}, index=${index}`);
templateData.titleToolbar.context = element;
templateData.rowContainer.classList.toggle('interactive-request', isRequestVM(element));
templateData.rowContainer.classList.toggle('interactive-response', isResponseVM(element));
templateData.username.textContent = element.username;
@@ -192,7 +204,7 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
}
private basicRenderElement(markdownValue: string, element: InteractiveTreeItem, index: number, templateData: IInteractiveListItemTemplate) {
const result = this.renderMarkdown(element, index, new MarkdownString(markdownValue), templateData.elementDisposables, templateData);
const result = this.renderMarkdown(new MarkdownString(markdownValue), templateData.elementDisposables, templateData);
dom.clearNode(templateData.value);
templateData.value.appendChild(result.element);
templateData.elementDisposables.add(result);
@@ -243,7 +255,7 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
} else if (toRender) {
// Doing the progressive render
const plusCursor = toRender.match(/```.*$/) ? toRender + `\n${InteractiveListItemRenderer.cursorCharacter}` : toRender + ` ${InteractiveListItemRenderer.cursorCharacter}`;
const result = this.renderMarkdown(element, index, new MarkdownString(plusCursor), disposables, templateData, true);
const result = this.renderMarkdown(new MarkdownString(plusCursor), disposables, templateData, true);
dom.clearNode(templateData.value);
templateData.value.appendChild(result.element);
disposables.add(result);
@@ -262,27 +274,14 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
return !!isFullyRendered;
}
private renderMarkdown(element: InteractiveTreeItem, index: number, markdown: IMarkdownString, disposables: DisposableStore, templateData: IInteractiveListItemTemplate, fillInIncompleteTokens = false): IMarkdownRenderResult {
private renderMarkdown(markdown: IMarkdownString, disposables: DisposableStore, templateData: IInteractiveListItemTemplate, fillInIncompleteTokens = false): IMarkdownRenderResult {
const disposablesList: IDisposable[] = [];
const result = this.renderer.render(markdown, {
fillInIncompleteTokens,
codeBlockRendererSync: (languageId, value) => {
const editorInfo = this._editorPool.get();
disposablesList.push(editorInfo);
editorInfo.setText(value);
editorInfo.setLanguage(languageId);
const layoutEditor = (context: string) => {
editorInfo.layout(this._currentLayoutWidth);
};
layoutEditor('init');
disposables.add(editorInfo.textModel.onDidChangeContent(() => {
layoutEditor('textmodel');
}));
return editorInfo.element;
const ref = this.renderCodeBlock(languageId, value, disposables);
disposablesList.push(ref);
return ref.object.element;
}
});
@@ -290,6 +289,25 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
return result;
}
private renderCodeBlock(languageId: string, value: string, disposables: DisposableStore): IDisposableReference<IInteractiveResultEditorInfo> {
const ref = this._editorPool.get();
const editorInfo = ref.object;
editorInfo.setText(value);
editorInfo.setLanguage(languageId);
const layoutEditor = (context: string) => {
editorInfo.layout(this._currentLayoutWidth);
};
layoutEditor('init');
disposables.add(editorInfo.textModel.onDidChangeContent(() => {
layoutEditor('textmodel');
}));
return ref;
}
private getProgressiveMarkdownToRender(element: IInteractiveResponseViewModel): string | undefined {
const renderData = element.renderData ?? { renderedWordCount: 0, lastRenderTime: 0 };
const rate = this.getProgressiveRenderRate(element);
@@ -319,6 +337,7 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend
}
disposeTemplate(templateData: IInteractiveListItemTemplate): void {
templateData.templateDisposables.dispose();
}
}
@@ -379,12 +398,12 @@ interface IInteractiveResultEditorInfo {
dispose(): void;
}
class EditorPool extends Disposable {
private _pool: ResourcePool<IInteractiveResultEditorInfo>;
class CodeBlockPart extends Disposable implements IInteractiveResultEditorInfo {
private readonly editor: CodeEditorWidget;
private readonly toolbar: MenuWorkbenchToolBar;
public get inUse(): ReadonlySet<IInteractiveResultEditorInfo> {
return this._pool.inUse;
}
public readonly textModel: ITextModel;
public readonly element: HTMLElement;
constructor(
private readonly options: InteractiveSessionEditorOptions,
@@ -393,15 +412,17 @@ class EditorPool extends Disposable {
@IModelService private readonly modelService: IModelService,
) {
super();
this._pool = this._register(new ResourcePool(() => this.editorFactory()));
// TODO listen to changes on options
}
private editorFactory(): IInteractiveResultEditorInfo {
const disposables = new DisposableStore();
const wrapper = $('.interactive-result-editor-wrapper');
const editor = disposables.add(this.instantiationService.createInstance(CodeEditorWidget, wrapper, {
this.element = $('.interactive-result-editor-wrapper');
this.toolbar = disposables.add(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.element, MenuId.InteractiveSessionCodeBlock, {
menuOptions: {
shouldForwardArgs: true
}
}));
const editorElement = dom.append(this.element, $('.interactive-result-editor'));
this.editor = disposables.add(this.instantiationService.createInstance(CodeEditorWidget, editorElement, {
...getSimpleEditorOptions(),
readOnly: true,
wordWrap: 'off',
@@ -435,65 +456,90 @@ class EditorPool extends Disposable {
}));
const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName('javascript');
const textModel = disposables.add(this.modelService.createModel('', this.languageService.createById(vscodeLanguageId), undefined));
editor.setModel(textModel);
return {
element: wrapper,
textModel,
layout: (width: number) => {
const realContentHeight = editor.getContentHeight();
editor.layout({ width, height: realContentHeight });
},
setText: (newText: string) => {
let currentText = textModel.getLinesContent().join('\n');
if (newText === currentText) {
return;
}
let removedChars = 0;
if (currentText.endsWith(` ${InteractiveListItemRenderer.cursorCharacter}`)) {
removedChars = 2;
} else if (currentText.endsWith(InteractiveListItemRenderer.cursorCharacter)) {
removedChars = 1;
}
if (removedChars > 0) {
currentText = currentText.slice(0, currentText.length - removedChars);
}
if (newText.startsWith(currentText)) {
const text = newText.slice(currentText.length);
const lastLine = textModel.getLineCount();
const lastCol = textModel.getLineMaxColumn(lastLine);
const insertAtCol = lastCol - removedChars;
textModel.applyEdits([{ range: new Range(lastLine, insertAtCol, lastLine, lastCol), text }]);
} else {
// console.log(`Failed to optimize setText`);
textModel.setValue(newText);
}
},
setLanguage: (languageId: string) => {
const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName(languageId);
if (vscodeLanguageId) {
textModel.setLanguage(vscodeLanguageId);
}
},
dispose: () => {
disposables.dispose();
}
};
this.textModel = disposables.add(this.modelService.createModel('', this.languageService.createById(vscodeLanguageId), undefined));
this.editor.setModel(this.textModel);
}
get(): IInteractiveResultEditorInfo {
layout(width: number): void {
const realContentHeight = this.editor.getContentHeight();
this.editor.layout({ width, height: realContentHeight });
}
setText(newText: string): void {
this.toolbar.context = newText;
let currentText = this.textModel.getLinesContent().join('\n');
if (newText === currentText) {
return;
}
let removedChars = 0;
if (currentText.endsWith(` ${InteractiveListItemRenderer.cursorCharacter}`)) {
removedChars = 2;
} else if (currentText.endsWith(InteractiveListItemRenderer.cursorCharacter)) {
removedChars = 1;
}
if (removedChars > 0) {
currentText = currentText.slice(0, currentText.length - removedChars);
}
if (newText.startsWith(currentText)) {
const text = newText.slice(currentText.length);
const lastLine = this.textModel.getLineCount();
const lastCol = this.textModel.getLineMaxColumn(lastLine);
const insertAtCol = lastCol - removedChars;
this.textModel.applyEdits([{ range: new Range(lastLine, insertAtCol, lastLine, lastCol), text }]);
} else {
// console.log(`Failed to optimize setText`);
this.textModel.setValue(newText);
}
}
setLanguage(languageId: string): void {
const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName(languageId);
if (vscodeLanguageId) {
this.textModel.setLanguage(vscodeLanguageId);
}
}
}
interface IDisposableReference<T> extends IDisposable {
object: T;
}
class EditorPool extends Disposable {
private _pool: ResourcePool<IInteractiveResultEditorInfo>;
public get inUse(): ReadonlySet<IInteractiveResultEditorInfo> {
return this._pool.inUse;
}
constructor(
private readonly options: InteractiveSessionEditorOptions,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this._pool = this._register(new ResourcePool(() => this.editorFactory()));
// TODO listen to changes on options
}
private editorFactory(): IInteractiveResultEditorInfo {
return this.instantiationService.createInstance(CodeBlockPart, this.options);
}
get(): IDisposableReference<IInteractiveResultEditorInfo> {
const object = this._pool.get();
return {
...object,
object,
dispose: () => this._pool.release(object)
};
}
}
// TODO does something in lifecycle.ts cover this?
class ResourcePool<T extends IDisposable> extends Disposable {
private readonly pool: T[] = [];
@@ -0,0 +1,69 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Codicon } from 'vs/base/common/codicons';
import { ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import { localize } from 'vs/nls';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { INTERACTIVE_SESSION_CATEGORY } from 'vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionActions';
import { isRequestVM, isResponseVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel';
export function registerInteractiveSessionTitleActions() {
registerAction2(class VoteUpAction extends Action2 {
constructor() {
super({
id: 'workbench.action.interactiveSession.voteUp',
title: {
value: localize('interactive.voteUp.label', "Vote Up"),
original: 'Vote Up'
},
f1: false,
category: INTERACTIVE_SESSION_CATEGORY,
icon: Codicon.thumbsup,
menu: {
id: MenuId.InteractiveSessionTitle,
group: 'navigation',
}
});
}
run(accessor: ServicesAccessor, ...args: any[]) {
const item = args[0];
if (!isRequestVM(item) && !isResponseVM(item)) {
return;
}
// TODO call provider method
}
});
registerAction2(class VoteDownAction extends Action2 {
constructor() {
super({
id: 'workbench.action.interactiveSession.voteDown',
title: {
value: localize('interactive.voteDown.label', "Vote Down"),
original: 'Vote Down'
},
f1: false,
category: INTERACTIVE_SESSION_CATEGORY,
icon: Codicon.thumbsdown,
menu: {
id: MenuId.InteractiveSessionTitle,
group: 'navigation',
}
});
}
run(accessor: ServicesAccessor, ...args: any[]) {
const item = args[0];
if (!isRequestVM(item) && !isResponseVM(item)) {
return;
}
// TODO call provider method
}
});
}
@@ -24,12 +24,17 @@
.interactive-list .interactive-item-container .header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.interactive-list .interactive-item-container .header .user {
display: flex;
align-items: center;
gap: 6px;
}
.interactive-list .interactive-item-container .header h3 {
.interactive-list .interactive-item-container .header .username {
margin: 0;
font-size: 12px;
font-weight: 600;
@@ -55,6 +60,14 @@
color: var(--vscode-badge-foreground);
}
.interactive-list .interactive-item-container .header .monaco-toolbar {
display: none;
}
.interactive-list .interactive-item-container.interactive-response:hover .header .monaco-toolbar {
display: initial;
}
.interactive-list .interactive-item-container .value {
width: 100%;
overflow: hidden;
@@ -139,6 +152,25 @@
width: 100%;
}
.interactive-session .interactive-result-editor-wrapper {
position: relative;
}
.interactive-session .interactive-result-editor-wrapper .monaco-toolbar {
display: none;
position: absolute;
top: -13px;
right: 10px;
height: 26px;
background-color: var(--vscode-interactive-result-editor-background-color);
border: 1px solid var(--vscode-interactive-responseBorder);
z-index: 100;
}
.interactive-session .interactive-result-editor-wrapper:hover .monaco-toolbar {
display: initial;
}
.interactive-session .interactive-result-editor-wrapper .monaco-editor,
.interactive-session .interactive-result-editor-wrapper .monaco-editor .overflow-guard {
border-radius: 4px;