diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 548ade8fc3d9..353edd973bbe 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -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 diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionCodeblockActions.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionCodeblockActions.ts new file mode 100644 index 000000000000..ca3fe0cddab8 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionCodeblockActions.ts @@ -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); + // } + // }); +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts index 2ed934268c41..e8aaeed77ceb 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts @@ -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 { + 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; +class CodeBlockPart extends Disposable implements IInteractiveResultEditorInfo { + private readonly editor: CodeEditorWidget; + private readonly toolbar: MenuWorkbenchToolBar; - public get inUse(): ReadonlySet { - 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 extends IDisposable { + object: T; +} + +class EditorPool extends Disposable { + private _pool: ResourcePool; + + public get inUse(): ReadonlySet { + 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 { const object = this._pool.get(); return { - ...object, + object, dispose: () => this._pool.release(object) }; } } +// TODO does something in lifecycle.ts cover this? + class ResourcePool extends Disposable { private readonly pool: T[] = []; diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionTitleActions.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionTitleActions.ts new file mode 100644 index 000000000000..3cab7582511b --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionTitleActions.ts @@ -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 + } + }); +} diff --git a/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css b/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css index 646a5de9667b..f9fbb37bbfd5 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css +++ b/src/vs/workbench/contrib/interactiveSession/browser/media/interactiveSession.css @@ -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;