From 9084e081d4e89ed8ab67fce340d573c4e1378939 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 14 May 2023 05:51:23 -0700 Subject: [PATCH] Navigate chat codeblocks with a keyboard shortcuut (#182361) Navigate codeblocks with a keyboard shortcuut --- .../interactiveSessionCodeblockActions.ts | 96 +++++++++++++++++-- .../browser/interactiveSession.ts | 10 +- .../browser/interactiveSessionListRenderer.ts | 63 ++++++++---- .../browser/interactiveSessionWidget.ts | 12 ++- 4 files changed, 150 insertions(+), 31 deletions(-) diff --git a/src/vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionCodeblockActions.ts b/src/vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionCodeblockActions.ts index 91be44018e938..48b85e90e1559 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionCodeblockActions.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionCodeblockActions.ts @@ -20,9 +20,10 @@ import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegis import { TerminalLocation } from 'vs/platform/terminal/common/terminal'; import { IUntitledTextResourceEditorInput } from 'vs/workbench/common/editor'; import { INTERACTIVE_SESSION_CATEGORY } from 'vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionActions'; -import { codeBlockInfoByModelUri } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer'; +import { IInteractiveSessionWidgetService } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSession'; +import { CONTEXT_IN_INTERACTIVE_SESSION } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContextKeys'; import { IInteractiveSessionCopyAction, IInteractiveSessionService, IInteractiveSessionUserActionEvent, InteractiveSessionCopyKind } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; -import { IInteractiveResponseViewModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; +import { IInteractiveResponseViewModel, isResponseVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; import { insertCell } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; import { INotebookEditor } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { CellKind, NOTEBOOK_EDITOR_ID } from 'vs/workbench/contrib/notebook/common/notebookCommon'; @@ -97,7 +98,7 @@ export function registerInteractiveSessionCodeBlockActions() { return false; } - const context = getContextFromEditor(editor); + const context = getContextFromEditor(editor, accessor); if (!context) { return false; } @@ -155,7 +156,7 @@ export function registerInteractiveSessionCodeBlockActions() { override async runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { let context = args[0]; if (!isCodeBlockActionContext(context)) { - context = getContextFromEditor(editor); + context = getContextFromEditor(editor, accessor); if (!isCodeBlockActionContext(context)) { return; } @@ -264,7 +265,7 @@ export function registerInteractiveSessionCodeBlockActions() { override async runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { let context = args[0]; if (!isCodeBlockActionContext(context)) { - context = getContextFromEditor(editor); + context = getContextFromEditor(editor, accessor); if (!isCodeBlockActionContext(context)) { return; } @@ -316,7 +317,7 @@ export function registerInteractiveSessionCodeBlockActions() { override async runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { let context = args[0]; if (!isCodeBlockActionContext(context)) { - context = getContextFromEditor(editor); + context = getContextFromEditor(editor, accessor); if (!isCodeBlockActionContext(context)) { return; } @@ -357,15 +358,94 @@ export function registerInteractiveSessionCodeBlockActions() { }); } }); + + function navigateCodeBlocks(accessor: ServicesAccessor, reverse?: boolean): void { + const codeEditorService = accessor.get(ICodeEditorService); + const interactiveSessionWidgetService = accessor.get(IInteractiveSessionWidgetService); + const widget = interactiveSessionWidgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const editor = codeEditorService.getFocusedCodeEditor(); + const editorUri = editor?.getModel()?.uri; + const curCodeBlockInfo = editorUri ? widget.getCodeBlockInfoForEditor(editorUri) : undefined; + + const focusResponse = curCodeBlockInfo ? + curCodeBlockInfo.element : + widget.viewModel?.getItems().reverse().find((item): item is IInteractiveResponseViewModel => isResponseVM(item)); + if (!focusResponse) { + return; + } + + const responseCodeblocks = widget.getCodeBlockInfosForResponse(focusResponse); + const focusIdx = curCodeBlockInfo ? + (curCodeBlockInfo.codeBlockIndex + (reverse ? -1 : 1) + responseCodeblocks.length) % responseCodeblocks.length : + reverse ? responseCodeblocks.length - 1 : 0; + + responseCodeblocks[focusIdx]?.focus(); + } + + registerAction2(class NextCodeBlockAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.interactiveSession.nextCodeBlock', + title: { + value: localize('interactive.nextCodeBlock.label', "Next Code Block"), + original: 'Next Code Block' + }, + keybinding: { + primary: KeyCode.F9, + weight: KeybindingWeight.WorkbenchContrib, + when: CONTEXT_IN_INTERACTIVE_SESSION, + }, + f1: true, + category: INTERACTIVE_SESSION_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + navigateCodeBlocks(accessor); + } + }); + + registerAction2(class PreviousCodeBlockAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.interactiveSession.previousCodeBlock', + title: { + value: localize('interactive.previousCodeBlock.label', "Previous Code Block"), + original: 'Previous Code Block' + }, + keybinding: { + primary: KeyMod.Shift | KeyCode.F9, + weight: KeybindingWeight.WorkbenchContrib, + when: CONTEXT_IN_INTERACTIVE_SESSION, + }, + f1: true, + category: INTERACTIVE_SESSION_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + navigateCodeBlocks(accessor, true); + } + }); } -function getContextFromEditor(editor: ICodeEditor): IInteractiveSessionCodeBlockActionContext | undefined { +function getContextFromEditor(editor: ICodeEditor, accessor: ServicesAccessor): IInteractiveSessionCodeBlockActionContext | undefined { + const interactiveSessionWidgetService = accessor.get(IInteractiveSessionWidgetService); const model = editor.getModel(); if (!model) { return; } - const codeBlockInfo = codeBlockInfoByModelUri.get(model.uri); + const widget = interactiveSessionWidgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const codeBlockInfo = widget.getCodeBlockInfoForEditor(model.uri); if (!codeBlockInfo) { return; } diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts index 4cf063d40dfc4..777772ff39da9 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSession.ts @@ -5,7 +5,7 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; -import { IInteractiveSessionViewModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; +import { IInteractiveResponseViewModel, IInteractiveSessionViewModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; import { Event } from 'vs/base/common/event'; import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -29,6 +29,12 @@ export interface IInteractiveSessionWidgetService { getWidgetByInputUri(uri: URI): IInteractiveSessionWidget | undefined; } +export interface IInteractiveSessionCodeBlockInfo { + codeBlockIndex: number; + element: IInteractiveResponseViewModel; + focus(): void; +} + export type IInteractiveSessionWidgetViewContext = { viewId: string } | { resource: boolean }; export interface IInteractiveSessionWidget { @@ -42,6 +48,8 @@ export interface IInteractiveSessionWidget { focusLastMessage(): void; focusInput(): void; getSlashCommands(): Promise; + getCodeBlockInfoForEditor(uri: URI): IInteractiveSessionCodeBlockInfo | undefined; + getCodeBlockInfosForResponse(response: IInteractiveResponseViewModel): IInteractiveSessionCodeBlockInfo[]; } export interface IInteractiveSessionViewPane { diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts index 9a91e199292d7..fa3d43a408e68 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer.ts @@ -16,11 +16,12 @@ import { Codicon } from 'vs/base/common/codicons'; import { Emitter, Event } from 'vs/base/common/event'; import { FuzzyScore } from 'vs/base/common/filters'; import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent'; -import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { ResourceMap } from 'vs/base/common/map'; import { FileAccess } from 'vs/base/common/network'; import { ThemeIcon } from 'vs/base/common/themables'; import { withNullAsUndefined } from 'vs/base/common/types'; +import { URI } from 'vs/base/common/uri'; import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; import { EDITOR_FONT_DEFAULTS, IEditorOptions } from 'vs/editor/common/config/editorOptions'; @@ -49,6 +50,8 @@ import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; 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 { IInteractiveSessionCodeBlockActionContext } from 'vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionCodeblockActions'; +import { IInteractiveSessionCodeBlockInfo } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSession'; import { InteractiveSessionFollowups } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionFollowups'; import { InteractiveSessionEditorOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions'; import { CONTEXT_RESPONSE_HAS_PROVIDER_ID, CONTEXT_RESPONSE_VOTE } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContextKeys'; @@ -87,6 +90,9 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend static readonly cursorCharacter = '\u258c'; static readonly ID = 'item'; + private readonly codeBlocksByResponseId = new Map(); + private readonly codeBlocksByEditorUri = new ResourceMap(); + private readonly renderer: MarkdownRenderer; protected readonly _onDidClickFollowup = this._register(new Emitter()); @@ -152,6 +158,15 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend return 8; } + getCodeBlockInfosForResponse(response: IInteractiveResponseViewModel): IInteractiveSessionCodeBlockInfo[] { + const codeBlocks = this.codeBlocksByResponseId.get(response.id); + return codeBlocks ?? []; + } + + getCodeBlockInfoForEditor(uri: URI): IInteractiveSessionCodeBlockInfo | undefined { + return this.codeBlocksByEditorUri.get(uri); + } + setVisible(visible: boolean): void { this._isVisible = visible; } @@ -385,10 +400,13 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend const usedSlashCommand = slashCommands.find(s => markdown.value.startsWith(`/${s.command} `)); const toRender = usedSlashCommand ? markdown.value.slice(usedSlashCommand.command.length + 2) : markdown.value; markdown = new MarkdownString(toRender); + + const codeblocks: IInteractiveSessionCodeBlockInfo[] = []; const result = this.renderer.render(markdown, { fillInIncompleteTokens, codeBlockRendererSync: (languageId, text) => { - const ref = this.renderCodeBlock({ languageId, text, codeBlockIndex: codeBlockIndex++, element, parentContextKeyService: templateData.contextKeyService }, disposables); + const data = { languageId, text, codeBlockIndex: codeBlockIndex++, element, parentContextKeyService: templateData.contextKeyService }; + const ref = this.renderCodeBlock(data, disposables); // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) @@ -397,11 +415,28 @@ export class InteractiveListItemRenderer extends Disposable implements ITreeRend this._onDidChangeItemHeight.fire({ element, height: templateData.rowContainer.offsetHeight }); })); + if (isResponseVM(element)) { + const info = { + codeBlockIndex: data.codeBlockIndex, + element, + focus() { + ref.object.focus(); + } + }; + codeblocks.push(info); + this.codeBlocksByEditorUri.set(ref.object.textModel.uri, info); + disposables.add(toDisposable(() => this.codeBlocksByEditorUri.delete(ref.object.textModel.uri))); + } disposablesList.push(ref); return ref.object.element; } }); + if (isResponseVM(element)) { + this.codeBlocksByResponseId.set(element.id, codeblocks); + disposables.add(toDisposable(() => this.codeBlocksByResponseId.delete(element.id))); + } + if (usedSlashCommand) { const slashCommandElement = $('span.interactive-slash-command', { title: usedSlashCommand.detail }, `/${usedSlashCommand.command} `); if (result.element.firstChild?.nodeName.toLowerCase() === 'p') { @@ -522,17 +557,10 @@ interface IInteractiveResultCodeBlockPart { readonly textModel: ITextModel; layout(width: number): void; render(data: IInteractiveResultCodeBlockData, width: number): void; + focus(): void; dispose(): void; } -export interface IInteractiveSessionCodeBlockInfo { - codeBlockIndex: number; - element: IInteractiveResponseViewModel; -} - -// Enable actions to look this up by editor URI. An alternative would be writing lots of details to element attributes. -export const codeBlockInfoByModelUri = new ResourceMap(); - const defaultCodeblockPadding = 10; class CodeBlockPart extends Disposable implements IInteractiveResultCodeBlockPart { @@ -620,6 +648,10 @@ class CodeBlockPart extends Disposable implements IInteractiveResultCodeBlockPar this.editor.setModel(this.textModel); } + 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" @@ -669,16 +701,7 @@ class CodeBlockPart extends Disposable implements IInteractiveResultCodeBlockPar this.layout(width); - if (isResponseVM(data.element) && data.element.providerResponseId) { - codeBlockInfoByModelUri.set(this.textModel.uri, { - element: data.element, - codeBlockIndex: data.codeBlockIndex, - }); - } else { - codeBlockInfoByModelUri.delete(this.textModel.uri); - } - - this.toolbar.context = { + this.toolbar.context = { code: data.text, codeBlockIndex: data.codeBlockIndex, element: data.element, diff --git a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts index d300f85eae7d2..b98d2f6e82852 100644 --- a/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts +++ b/src/vs/workbench/contrib/interactiveSession/browser/interactiveSessionWidget.ts @@ -22,7 +22,7 @@ import { ServiceCollection } from 'vs/platform/instantiation/common/serviceColle import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; import { IViewsService } from 'vs/workbench/common/views'; import { clearChatSession } from 'vs/workbench/contrib/interactiveSession/browser/actions/interactiveSessionClear'; -import { IInteractiveSessionWidget, IInteractiveSessionWidgetService, IInteractiveSessionWidgetViewContext } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSession'; +import { IInteractiveSessionCodeBlockInfo, IInteractiveSessionWidget, IInteractiveSessionWidgetService, IInteractiveSessionWidgetViewContext } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSession'; import { InteractiveSessionInputPart } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionInputPart'; import { IInteractiveSessionRendererDelegate, InteractiveListItemRenderer, InteractiveSessionAccessibilityProvider, InteractiveSessionListDelegate, InteractiveTreeItem } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionListRenderer'; import { InteractiveSessionEditorOptions } from 'vs/workbench/contrib/interactiveSession/browser/interactiveSessionOptions'; @@ -31,7 +31,7 @@ import { CONTEXT_INTERACTIVE_REQUEST_IN_PROGRESS, CONTEXT_IN_INTERACTIVE_SESSION import { IInteractiveSessionContributionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionContributionService'; import { IInteractiveSessionModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel'; import { IInteractiveSessionReplyFollowup, IInteractiveSessionService, IInteractiveSlashCommand } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; -import { InteractiveSessionViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; +import { IInteractiveResponseViewModel, InteractiveSessionViewModel, isRequestVM, isResponseVM, isWelcomeVM } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionViewModel'; const $ = dom.$; @@ -386,6 +386,14 @@ export class InteractiveSessionWidget extends Disposable implements IInteractive } } + getCodeBlockInfosForResponse(response: IInteractiveResponseViewModel): IInteractiveSessionCodeBlockInfo[] { + return this.renderer.getCodeBlockInfosForResponse(response); + } + + getCodeBlockInfoForEditor(uri: URI): IInteractiveSessionCodeBlockInfo | undefined { + return this.renderer.getCodeBlockInfoForEditor(uri); + } + focusLastMessage(): void { if (!this.viewModel) { return;