From 8d73d94ae4b8ba03ec0e2c8d2f784ce214901dc6 Mon Sep 17 00:00:00 2001 From: aamunger Date: Wed, 9 Aug 2023 14:46:07 -0700 Subject: [PATCH] logic for when menu item is shown, copy text version of image --- .../browser/controller/copyOutputAction.ts | 51 +++++++++++--- .../browser/controller/editActions.ts | 2 +- .../browser/view/cellParts/cellOutput.ts | 67 ++++++++++++++----- .../browser/view/notebookRenderingCommon.ts | 12 ++++ 4 files changed, 103 insertions(+), 29 deletions(-) diff --git a/src/vs/workbench/contrib/notebook/browser/controller/copyOutputAction.ts b/src/vs/workbench/contrib/notebook/browser/controller/copyOutputAction.ts index a1029e05c67..eed21100b60 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/copyOutputAction.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/copyOutputAction.ts @@ -11,12 +11,17 @@ import { CELL_TITLE_OUTPUT_GROUP_ID, INotebookOutputActionContext, NotebookActio import { NOTEBOOK_CELL_HAS_OUTPUTS } from 'vs/workbench/contrib/notebook/common/notebookContextKeys'; import * as icons from 'vs/workbench/contrib/notebook/browser/notebookIcons'; import { ILogService } from 'vs/platform/log/common/log'; +import { isTextStreamMime } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { ICellViewModel } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { CLIPBOARD_COMPATIBLE_MIMETYPES } from 'vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon'; + +export const COPY_OUTPUT_COMMAND_ID = 'notebook.cellOutput.copyToClipboard'; registerAction2(class CopyCellOutputAction extends NotebookAction { constructor() { super( { - id: 'CopyCellOutput', + id: COPY_OUTPUT_COMMAND_ID, title: localize('notebookActions.copyOutput', "Copy Output to Clipboard"), menu: { id: MenuId.NotebookOutputToolbar, @@ -28,23 +33,46 @@ registerAction2(class CopyCellOutputAction extends NotebookAction { } async runWithContext(accessor: ServicesAccessor, context: INotebookOutputActionContext): Promise { - const clipboardService = accessor.get(IClipboardService); - const logService = accessor.get(ILogService); - const outputViewModel = context.outputViewModel; const outputTextModel = outputViewModel.model; - const mimeType = outputViewModel.pickedMimeType?.mimeType; - const buffer = outputTextModel.outputs.find(output => output.mime === mimeType); - if (!buffer || !mimeType) { + const mimeType = outputViewModel.pickedMimeType?.mimeType; + const output = mimeType && CLIPBOARD_COMPATIBLE_MIMETYPES.includes(mimeType) ? + outputTextModel.outputs.find(output => output.mime === mimeType) : + outputTextModel.outputs.find(output => CLIPBOARD_COMPATIBLE_MIMETYPES.includes(output.mime)); + + if (!mimeType || !output) { return; } - const charLimit = 100_000; + const charLimit = 100_000_000; const decoder = new TextDecoder(); - let text = decoder.decode(buffer.data.slice(0, charLimit).buffer); + let text = decoder.decode(output.data.slice(0, charLimit).buffer); + let totalLength = output.data.byteLength; - if (buffer.data.byteLength > charLimit) { + // append adjacent text streams since they are concatenated in the renderer + if (isTextStreamMime(mimeType)) { + const cellViewModel = outputViewModel.cellViewModel as ICellViewModel; + let index = cellViewModel.outputsViewModels.indexOf(outputViewModel) + 1; + while (index < cellViewModel.outputsViewModels.length && text.length < charLimit) { + const nextOutputViewModel = cellViewModel.outputsViewModels[index]; + const nextMimeType = nextOutputViewModel?.pickedMimeType?.mimeType; + const nextOutputTextModel = cellViewModel.model.outputs[index]; + + if (!nextOutputViewModel || !nextMimeType || !isTextStreamMime(nextMimeType)) { + break; + } + + const nextOutput = nextOutputTextModel.outputs.find(output => output.mime === nextMimeType); + if (nextOutput) { + text = text + decoder.decode(nextOutput.data.slice(0, charLimit).buffer); + totalLength = totalLength + nextOutput.data.byteLength; + } + index = index + 1; + } + } + + if (totalLength > charLimit) { text = text + '...(truncated)'; } @@ -52,12 +80,13 @@ registerAction2(class CopyCellOutputAction extends NotebookAction { text = text.replace(/\\u001b\[[0-9;]*m/gi, '').replaceAll('\\n', '\n'); } + const clipboardService = accessor.get(IClipboardService); + const logService = accessor.get(ILogService); try { await clipboardService.writeText(text); } catch (e) { logService.error(`Failed to copy content: ${e}`); } - } }); diff --git a/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts b/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts index 097d84adc7c..065ddc1796b 100644 --- a/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts +++ b/src/vs/workbench/contrib/notebook/browser/controller/editActions.ts @@ -37,7 +37,7 @@ import { InlineChatController } from 'vs/workbench/contrib/inlineChat/browser/in const CLEAR_ALL_CELLS_OUTPUTS_COMMAND_ID = 'notebook.clearAllCellsOutputs'; const EDIT_CELL_COMMAND_ID = 'notebook.cell.edit'; const DELETE_CELL_COMMAND_ID = 'notebook.cell.delete'; -const CLEAR_CELL_OUTPUTS_COMMAND_ID = 'notebook.cell.clearOutputs'; +export const CLEAR_CELL_OUTPUTS_COMMAND_ID = 'notebook.cell.clearOutputs'; registerAction2(class EditCellAction extends NotebookCellAction { constructor() { diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts index 4dca2df7df3..8718f3b48e8 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellOutput.ts @@ -25,14 +25,16 @@ import { INotebookOutputActionContext } from 'vs/workbench/contrib/notebook/brow import { ICellOutputViewModel, ICellViewModel, IInsetRenderOutput, INotebookEditorDelegate, JUPYTER_EXTENSION_ID, RenderOutputType } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { mimetypeIcon } from 'vs/workbench/contrib/notebook/browser/notebookIcons'; import { CellContentPart } from 'vs/workbench/contrib/notebook/browser/view/cellPart'; -import { CodeCellRenderTemplate } from 'vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon'; +import { CLIPBOARD_COMPATIBLE_MIMETYPES, CodeCellRenderTemplate } from 'vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon'; import { CodeCellViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/codeCellViewModel'; import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; -import { CellUri, IOrderedMimeType, NotebookCellOutputsSplice, RENDERER_NOT_AVAILABLE } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellUri, IOrderedMimeType, NotebookCellOutputsSplice, RENDERER_NOT_AVAILABLE, isTextStreamMime } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { INotebookExecutionStateService } from 'vs/workbench/contrib/notebook/common/notebookExecutionStateService'; import { INotebookKernel } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; import { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/browser/panecomposite'; +import { COPY_OUTPUT_COMMAND_ID } from 'vs/workbench/contrib/notebook/browser/controller/copyOutputAction'; +import { CLEAR_CELL_OUTPUTS_COMMAND_ID } from 'vs/workbench/contrib/notebook/browser/controller/editActions'; interface IMimeTypeRenderer extends IQuickPickItem { index: number; @@ -253,9 +255,29 @@ class CellOutputElement extends Disposable { return { type: RenderOutputType.Html, source: viewModel, htmlContent: el.outerHTML }; } + private shouldEnableCopy(mimeTypes: readonly IOrderedMimeType[]) { + if (!mimeTypes.find(mimeType => CLIPBOARD_COMPATIBLE_MIMETYPES.indexOf(mimeType.mimeType))) { + return false; + } + + if (isTextStreamMime(mimeTypes[0].mimeType)) { + const cellViewModel = this.output.cellViewModel as ICellViewModel; + const index = cellViewModel.outputsViewModels.indexOf(this.output); + if (index > 0) { + const previousOutput = cellViewModel.model.outputs[index - 1]; + // if the previous output was also a stream, the copy command will be in that output instead + return !isTextStreamMime(previousOutput.outputs[0].mime); + } + } + + return true; + } + private async _attachToolbar(outputItemDiv: HTMLElement, notebookTextModel: NotebookTextModel, kernel: INotebookKernel | undefined, index: number, mimeTypes: readonly IOrderedMimeType[]) { const hasMultipleMimeTypes = mimeTypes.filter(mimeType => mimeType.isTrusted).length > 1; - if (index > 0 && !hasMultipleMimeTypes) { + const isCopyEnabled = this.shouldEnableCopy(mimeTypes); + if (index > 0 && !hasMultipleMimeTypes && !isCopyEnabled) { + // nothing to put in the toolbar return; } @@ -284,21 +306,30 @@ class CellOutputElement extends Disposable { // TODO: This could probably be a real registered action, but it has to talk to this output element const pickAction = new Action('notebook.output.pickMimetype', nls.localize('pickMimeType', "Change Presentation"), ThemeIcon.asClassName(mimetypeIcon), undefined, async _context => this._pickActiveMimeTypeRenderer(outputItemDiv, notebookTextModel, kernel, this.output)); - if (index === 0 && useConsolidatedButton) { - const menu = this._renderDisposableStore.add(this.menuService.createMenu(MenuId.NotebookOutputToolbar, this.contextKeyService)); - const updateMenuToolbar = () => { - const primary: IAction[] = []; - const secondary: IAction[] = []; - const result = { primary, secondary }; - createAndFillInActionBarActions(menu, { shouldForwardArgs: true }, result, () => false); - toolbar.setActions([], [pickAction, ...secondary]); - }; - updateMenuToolbar(); - this._renderDisposableStore.add(menu.onDidChange(updateMenuToolbar)); - } else { - toolbar.setActions([pickAction]); - } + const menu = this._renderDisposableStore.add(this.menuService.createMenu(MenuId.NotebookOutputToolbar, this.contextKeyService)); + const updateMenuToolbar = () => { + const primary: IAction[] = []; + let secondary: IAction[] = []; + const result = { primary, secondary }; + + createAndFillInActionBarActions(menu, { shouldForwardArgs: true }, result, () => false); + if (index > 0 || !useConsolidatedButton) { + // clear outputs should only appear in the first output item's menu + secondary = secondary.filter((action) => action.id !== CLEAR_CELL_OUTPUTS_COMMAND_ID); + } + if (!isCopyEnabled) { + secondary = secondary.filter((action) => action.id !== COPY_OUTPUT_COMMAND_ID); + } + if (hasMultipleMimeTypes) { + secondary = [pickAction, ...secondary]; + } + + toolbar.setActions([], secondary); + }; + updateMenuToolbar(); + this._renderDisposableStore.add(menu.onDidChange(updateMenuToolbar)); + } private async _pickActiveMimeTypeRenderer(outputItemDiv: HTMLElement, notebookTextModel: NotebookTextModel, kernel: INotebookKernel | undefined, viewModel: ICellOutputViewModel) { @@ -756,3 +787,5 @@ const JUPYTER_RENDERER_MIMETYPES = [ 'application/vnd.jupyter.widget-view+json', 'application/vnd.code.notebook.error' ]; + + diff --git a/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts b/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts index 68a9cc8af76..8293f558b94 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/notebookRenderingCommon.ts @@ -114,3 +114,15 @@ export interface CodeCellRenderTemplate extends BaseCellRenderTemplate { focusSinkElement: HTMLElement; editor: ICodeEditor; } + +export const CLIPBOARD_COMPATIBLE_MIMETYPES = [ + 'text/latex', + 'text/html', + 'application/vnd.code.notebook.error', + 'application/vnd.code.notebook.stdout', + 'application/x.notebook.stdout', + 'application/x.notebook.stream', + 'application/vnd.code.notebook.stderr', + 'application/x.notebook.stderr', + 'text/plain' +];