diff --git a/src/vs/base/common/mime.ts b/src/vs/base/common/mime.ts index dcaca6245b6..368d77953e6 100644 --- a/src/vs/base/common/mime.ts +++ b/src/vs/base/common/mime.ts @@ -316,3 +316,20 @@ export function getExtensionForMimeType(mimeType: string): string | undefined { return undefined; } + +const _simplePattern = /^(.+)\/(.+?)(;.+)?$/; + +export function normalizeMimeType(mimeType: string): string; +export function normalizeMimeType(mimeType: string, strict: true): string | undefined; +export function normalizeMimeType(mimeType: string, strict?: true): string | undefined { + + const match = _simplePattern.exec(mimeType); + if (!match) { + return strict + ? undefined + : mimeType; + } + // https://datatracker.ietf.org/doc/html/rfc2045#section-5.1 + // media and subtype must ALWAYS be lowercase, parameter not + return `${match[1].toLowerCase()}/${match[2].toLowerCase()}${match[3] ?? ''}`; +} diff --git a/src/vs/base/test/common/mime.test.ts b/src/vs/base/test/common/mime.test.ts index f2583fed404..05515ed1e59 100644 --- a/src/vs/base/test/common/mime.test.ts +++ b/src/vs/base/test/common/mime.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { guessMimeTypes, registerTextMime } from 'vs/base/common/mime'; +import { guessMimeTypes, normalizeMimeType, registerTextMime } from 'vs/base/common/mime'; import { URI } from 'vs/base/common/uri'; suite('Mime', () => { @@ -126,4 +126,13 @@ suite('Mime', () => { assert.deepStrictEqual(guessMimeTypes(URI.parse(`data:;label:something.data;description:data,`)), ['text/data', 'text/plain']); }); + + test('normalize', () => { + assert.strictEqual(normalizeMimeType('invalid'), 'invalid'); + assert.strictEqual(normalizeMimeType('invalid', true), undefined); + assert.strictEqual(normalizeMimeType('Text/plain'), 'text/plain'); + assert.strictEqual(normalizeMimeType('Text/pläin'), 'text/pläin'); + assert.strictEqual(normalizeMimeType('Text/plain;UPPER'), 'text/plain;UPPER'); + assert.strictEqual(normalizeMimeType('Text/plain;lower'), 'text/plain;lower'); + }); }); diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index 6eb1e1f6e53..2464bd06ad7 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -16,10 +16,11 @@ import { asWebviewUri } from 'vs/workbench/api/common/shared/webview'; import { ResourceMap } from 'vs/base/common/map'; import { timeout } from 'vs/base/common/async'; import { ExtHostCell, ExtHostNotebookDocument } from 'vs/workbench/api/common/extHostNotebookDocument'; -import { CellEditType, IImmediateCellEditOperation, NotebookCellExecutionState, NullablePartialNotebookCellInternalMetadata } from 'vs/workbench/contrib/notebook/common/notebookCommon'; +import { CellEditType, IImmediateCellEditOperation, IOutputDto, NotebookCellExecutionState, NullablePartialNotebookCellInternalMetadata } from 'vs/workbench/contrib/notebook/common/notebookCommon'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { asArray } from 'vs/base/common/arrays'; import { ILogService } from 'vs/platform/log/common/log'; +import { NotebookCellOutput } from 'vs/workbench/api/common/extHostTypes'; interface IKernelData { extensionId: ExtensionIdentifier, @@ -388,9 +389,24 @@ class NotebookCellExecutionTask extends Disposable { return cell.handle; } + private validateAndConvertOutputs(items: vscode.NotebookCellOutput[]): IOutputDto[] { + return items.map(output => { + const newOutput = NotebookCellOutput.ensureUniqueMimeTypes(output.outputs, true); + if (newOutput === output.outputs) { + return extHostTypeConverters.NotebookCellOutput.from(output); + } + return extHostTypeConverters.NotebookCellOutput.from({ + outputs: newOutput, + id: output.id, + metadata: output.metadata + }); + }); + } + asApiObject(): vscode.NotebookCellExecutionTask { const that = this; return Object.freeze({ + get token() { return that._tokenSource.token; }, get document() { return that._document.apiNotebook; }, get cell() { return that._cell.apiCell; }, @@ -439,30 +455,28 @@ class NotebookCellExecutionTask extends Disposable { async appendOutput(outputs: vscode.NotebookCellOutput | vscode.NotebookCellOutput[], cellIndex?: number): Promise { that.verifyStateForOutput(); const handle = that.cellIndexToHandle(cellIndex); - outputs = asArray(outputs); - return that.applyEditSoon({ editType: CellEditType.Output, handle, append: true, outputs: outputs.map(extHostTypeConverters.NotebookCellOutput.from) }); + const outputDtos = that.validateAndConvertOutputs(asArray(outputs)); + return that.applyEditSoon({ editType: CellEditType.Output, handle, append: true, outputs: outputDtos }); }, async replaceOutput(outputs: vscode.NotebookCellOutput | vscode.NotebookCellOutput[], cellIndex?: number): Promise { that.verifyStateForOutput(); const handle = that.cellIndexToHandle(cellIndex); - outputs = asArray(outputs); - return that.applyEditSoon({ editType: CellEditType.Output, handle, outputs: outputs.map(extHostTypeConverters.NotebookCellOutput.from) }); + const outputDtos = that.validateAndConvertOutputs(asArray(outputs)); + return that.applyEditSoon({ editType: CellEditType.Output, handle, outputs: outputDtos }); }, async appendOutputItems(items: vscode.NotebookCellOutputItem | vscode.NotebookCellOutputItem[], outputId: string): Promise { that.verifyStateForOutput(); - items = asArray(items); + items = NotebookCellOutput.ensureUniqueMimeTypes(asArray(items), true); return that.applyEditSoon({ editType: CellEditType.OutputItems, append: true, items: items.map(extHostTypeConverters.NotebookCellOutputItem.from), outputId }); }, async replaceOutputItems(items: vscode.NotebookCellOutputItem | vscode.NotebookCellOutputItem[], outputId: string): Promise { that.verifyStateForOutput(); - items = asArray(items); + items = NotebookCellOutput.ensureUniqueMimeTypes(asArray(items), true); return that.applyEditSoon({ editType: CellEditType.OutputItems, items: items.map(extHostTypeConverters.NotebookCellOutputItem.from), outputId }); - }, - - token: that._tokenSource.token + } }); } } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index fdccb8c119c..5700ee477dd 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -8,7 +8,7 @@ import { illegalArgument } from 'vs/base/common/errors'; import { IRelativePattern } from 'vs/base/common/glob'; import { isMarkdownString, MarkdownString as BaseMarkdownString } from 'vs/base/common/htmlContent'; import { ReadonlyMapView, ResourceMap } from 'vs/base/common/map'; -import { isFalsyOrWhitespace } from 'vs/base/common/strings'; +import { normalizeMimeType } from 'vs/base/common/mime'; import { isStringArray } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { generateUuid } from 'vs/base/common/uuid'; @@ -3156,15 +3156,39 @@ export class NotebookCellOutputItem { if (!(data instanceof Uint8Array)) { this.value = data; } - if (isFalsyOrWhitespace(this.mime)) { - throw new Error('INVALID mime type, must not be empty or falsy'); + + const mimeNormalized = normalizeMimeType(mime, true); + if (!mimeNormalized) { + throw new Error('INVALID mime type, must not be empty or falsy: ' + mime); } - // todo@joh stringify and check metadata and throw when not JSONable + this.mime = mimeNormalized; } } export class NotebookCellOutput { + static ensureUniqueMimeTypes(items: NotebookCellOutputItem[], warn: boolean = false): NotebookCellOutputItem[] { + const seen = new Set(); + const removeIdx = new Set(); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const normalMime = normalizeMimeType(item.mime); + if (!seen.has(normalMime)) { + seen.add(normalMime); + continue; + } + // duplicated mime types... first has won + removeIdx.add(i); + if (warn) { + console.warn(`DUPLICATED mime type '${item.mime}' will be dropped`); + } + } + if (removeIdx.size === 0) { + return items; + } + return items.filter((_item, index) => !removeIdx.has(index)); + } + id: string; outputs: NotebookCellOutputItem[]; metadata?: Record; @@ -3174,7 +3198,7 @@ export class NotebookCellOutput { idOrMetadata?: string | Record, metadata?: Record ) { - this.outputs = outputs; + this.outputs = NotebookCellOutput.ensureUniqueMimeTypes(outputs, true); if (typeof idOrMetadata === 'string') { this.id = idOrMetadata; this.metadata = metadata; diff --git a/src/vs/workbench/test/browser/api/extHostTypes.test.ts b/src/vs/workbench/test/browser/api/extHostTypes.test.ts index ac2117b6745..ee97d58990a 100644 --- a/src/vs/workbench/test/browser/api/extHostTypes.test.ts +++ b/src/vs/workbench/test/browser/api/extHostTypes.test.ts @@ -685,6 +685,11 @@ suite('ExtHostTypes', function () { test('NotebookCellOutputItem - factories', function () { + assert.throws(() => { + // invalid mime type + new types.NotebookCellOutputItem(new Uint8Array(), 'invalid'); + }); + // --- err let item = types.NotebookCellOutputItem.error(new Error()); @@ -698,8 +703,8 @@ suite('ExtHostTypes', function () { assert.strictEqual(item.mime, 'application/json'); assert.deepStrictEqual(item.data, new TextEncoder().encode(JSON.stringify(1))); - item = types.NotebookCellOutputItem.json(1, 'foo'); - assert.strictEqual(item.mime, 'foo'); + item = types.NotebookCellOutputItem.json(1, 'foo/bar'); + assert.strictEqual(item.mime, 'foo/bar'); assert.deepStrictEqual(item.data, new TextEncoder().encode(JSON.stringify(1))); item = types.NotebookCellOutputItem.json(true);