From 1982569c0c977268564e238ba275e561e74c5629 Mon Sep 17 00:00:00 2001 From: Luca Stocchi <49404737+lstocchi@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:48:32 +0200 Subject: [PATCH] feat: allow users to pick between WSL and HyperV if both enabled (#8864) (#9105) * feat: allow users to pick between WSL and HyperV if both enabled (#8864) Signed-off-by: lstocchi * fix: add tests Signed-off-by: lstocchi * fix: retrieve selected provider when creating machine Signed-off-by: lstocchi * fix: round memory to a multiple of 2Mb to be complaint to hyperv requirementes Signed-off-by: lstocchi * fix: disable usermode for hyperv Signed-off-by: lstocchi --------- Signed-off-by: lstocchi --- .../podman/packages/extension/package.json | 19 +++- .../packages/extension/src/extension.spec.ts | 48 ++++++---- .../packages/extension/src/extension.ts | 71 +++++++++++--- ...esConnectionCreationOrEditRendering.svelte | 93 ++++++++++++------- .../PreferencesRenderingItemFormat.spec.ts | 68 ++++++++++++++ .../PreferencesRenderingItemFormat.svelte | 17 +++- 6 files changed, 243 insertions(+), 73 deletions(-) diff --git a/extensions/podman/packages/extension/package.json b/extensions/podman/packages/extension/package.json index 2e81333608f2b..6e4112987087c 100644 --- a/extensions/podman/packages/extension/package.json +++ b/extensions/podman/packages/extension/package.json @@ -119,7 +119,7 @@ "maximum": "HOST_TOTAL_CPU", "scope": "ContainerProviderConnectionFactory", "description": "CPU(s)", - "when": "podman.podmanMachineCpuSupported" + "when": "podman.podmanMachineCpuSupported && !podman.isCreateWSLOptionSelected" }, "podman.factory.machine.memory": { "type": "number", @@ -130,7 +130,7 @@ "scope": "ContainerProviderConnectionFactory", "step": 500000000, "description": "Memory", - "when": "podman.podmanMachineMemorySupported" + "when": "podman.podmanMachineMemorySupported && !podman.isCreateWSLOptionSelected" }, "podman.factory.machine.diskSize": { "type": "number", @@ -141,7 +141,7 @@ "step": 500000000, "scope": "ContainerProviderConnectionFactory", "description": "Disk size", - "when": "podman.podmanMachineDiskSupported" + "when": "podman.podmanMachineDiskSupported && !podman.isCreateWSLOptionSelected" }, "podman.factory.machine.image-path": { "type": "string", @@ -161,7 +161,7 @@ "default": false, "scope": "ContainerProviderConnectionFactory", "markdownDescription": "User mode networking (traffic relayed by a user process). See [documentation](https://docs.podman.io/en/latest/markdown/podman-machine-init.1.html#user-mode-networking).", - "when": "podman.isUserModeNetworkingSupported == true" + "when": "podman.isUserModeNetworkingSupported == true && podman.isCreateWSLOptionSelected" }, "podman.factory.machine.provider": { "type": "string", @@ -174,6 +174,17 @@ "description": "Provider Type", "when": "podman.isLibkrunSupported" }, + "podman.factory.machine.win.provider": { + "type": "string", + "default": "wsl", + "enum": [ + "wsl", + "hyperv" + ], + "scope": "ContainerProviderConnectionFactory", + "description": "Provider Type", + "when": "podman.wslHypervEnabled" + }, "podman.factory.machine.now": { "type": "boolean", "default": true, diff --git a/extensions/podman/packages/extension/src/extension.spec.ts b/extensions/podman/packages/extension/src/extension.spec.ts index 8dc897ec674de..1f215260ef1bf 100644 --- a/extensions/podman/packages/extension/src/extension.spec.ts +++ b/extensions/podman/packages/extension/src/extension.spec.ts @@ -329,7 +329,7 @@ test('verify create command called with correct values', async () => { }); expect(spyExecPromise).toBeCalledWith( podmanCli.getPodmanCli(), - ['machine', 'init', '--cpus', '2', '--memory', '999', '--disk-size', '232', '--image-path', 'path', '--rootful'], + ['machine', 'init', '--cpus', '2', '--memory', '1000', '--disk-size', '232', '--image-path', 'path', '--rootful'], { logger: undefined, token: undefined, @@ -372,7 +372,7 @@ test('verify create command called with correct values with user mode networking '--cpus', '2', '--memory', - '999', + '1000', '--disk-size', '232', '--image-path', @@ -418,7 +418,7 @@ test('verify create command called with now flag if start machine after creation '--cpus', '2', '--memory', - '999', + '1000', '--disk-size', '232', '--image-path', @@ -2027,24 +2027,25 @@ describe('calcPodmanMachineSetting', () => { test('setValue to true if OS is MacOS', async () => { vi.mocked(isWindows).mockReturnValue(false); - await extension.calcPodmanMachineSetting(podmanConfiguration); + await extension.calcPodmanMachineSetting(); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_CPU_SUPPORTED_KEY, true); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_MEMORY_SUPPORTED_KEY, true); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_DISK_SUPPORTED_KEY, true); }); test('setValue to true if OS is Windows and uses HyperV - set env variable', async () => { vi.mocked(isWindows).mockReturnValue(true); - process.env.CONTAINERS_MACHINE_PROVIDER = 'hyperv'; - vi.spyOn(podmanConfiguration, 'matchRegexpInContainersConfig').mockResolvedValue(false); - await extension.calcPodmanMachineSetting(podmanConfiguration); - expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_CPU_SUPPORTED_KEY, true); - expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_MEMORY_SUPPORTED_KEY, true); - expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_DISK_SUPPORTED_KEY, true); - }); - test('setValue to true if OS is Windows and uses HyperV - set by config file', async () => { - vi.mocked(isWindows).mockReturnValue(true); - vi.spyOn(podmanConfiguration, 'matchRegexpInContainersConfig').mockResolvedValue(true); - await extension.calcPodmanMachineSetting(podmanConfiguration); + vi.spyOn(extensionApi.process, 'exec').mockImplementation((command, args) => { + return new Promise(resolve => { + if (command === 'powershell.exe') { + resolve({ + stdout: args?.[0] === '@(Get-Service vmms).Status' ? 'Running' : 'True', + stderr: '', + command: 'command', + }); + } + }); + }); + await extension.calcPodmanMachineSetting(); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_CPU_SUPPORTED_KEY, true); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_MEMORY_SUPPORTED_KEY, true); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_DISK_SUPPORTED_KEY, true); @@ -2053,7 +2054,7 @@ describe('calcPodmanMachineSetting', () => { vi.mocked(isWindows).mockReturnValue(true); process.env.CONTAINERS_MACHINE_PROVIDER = 'wsl'; vi.spyOn(podmanConfiguration, 'matchRegexpInContainersConfig').mockResolvedValue(false); - await extension.calcPodmanMachineSetting(podmanConfiguration); + await extension.calcPodmanMachineSetting(); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_CPU_SUPPORTED_KEY, false); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_MEMORY_SUPPORTED_KEY, false); expect(extensionApi.context.setValue).toBeCalledWith(extension.PODMAN_MACHINE_DISK_SUPPORTED_KEY, false); @@ -2619,3 +2620,18 @@ test('getJSONMachineList should get machines from hyperv and wsl if both are ena expect(execPodmanSpy).toHaveBeenNthCalledWith(1, ['machine', 'list', '--format', 'json'], 'wsl'); expect(execPodmanSpy).toHaveBeenNthCalledWith(2, ['machine', 'list', '--format', 'json'], 'hyperv'); }); + +describe('updateWSLHyperVEnabledValue', () => { + beforeEach(() => { + extension.updateWSLHyperVEnabledValue(true); + vi.resetAllMocks(); + }); + test('setValue should be called if new value is different than wslAndHypervEnabled', async () => { + extension.updateWSLHyperVEnabledValue(false); + expect(extensionApi.context.setValue).toBeCalledWith(extension.WSL_HYPERV_ENABLED_KEY, false); + }); + test('setValue should not be called if new value is equal to wslAndHypervEnabled', async () => { + extension.updateWSLHyperVEnabledValue(true); + expect(extensionApi.context.setValue).not.toBeCalled(); + }); +}); diff --git a/extensions/podman/packages/extension/src/extension.ts b/extensions/podman/packages/extension/src/extension.ts index dcbf021b0f177..760def89d824f 100644 --- a/extensions/podman/packages/extension/src/extension.ts +++ b/extensions/podman/packages/extension/src/extension.ts @@ -97,6 +97,9 @@ const krunkitHelper = new KrunkitHelper(); const podmanBinaryHelper = new PodmanBinaryLocationHelper(); const podmanInfoHelper = new PodmanInfoHelper(); +let createWSLMachineOptionSelected = false; +let wslAndHypervEnabled = false; + let shouldNotifySetup = true; const setupPodmanNotification: extensionApi.NotificationOptions = { title: 'Podman needs to be set up', @@ -1023,6 +1026,8 @@ export const PODMAN_MACHINE_CPU_SUPPORTED_KEY = 'podman.podmanMachineCpuSupporte export const PODMAN_MACHINE_MEMORY_SUPPORTED_KEY = 'podman.podmanMachineMemorySupported'; export const PODMAN_MACHINE_DISK_SUPPORTED_KEY = 'podman.podmanMachineDiskSupported'; export const PODMAN_PROVIDER_LIBKRUN_SUPPORTED_KEY = 'podman.isLibkrunSupported'; +export const CREATE_WSL_MACHINE_OPTION_SELECTED_KEY = 'podman.isCreateWSLOptionSelected'; +export const WSL_HYPERV_ENABLED_KEY = 'podman.wslHypervEnabled'; export function initTelemetryLogger(): void { telemetryLogger = extensionApi.env.createTelemetryLogger(); @@ -1276,6 +1281,8 @@ export async function activate(extensionContext: extensionApi.ExtensionContext): extensionApi.context.setValue(START_NOW_MACHINE_INIT_SUPPORTED_KEY, isStartNowAtMachineInitSupported(version)); extensionApi.context.setValue(USER_MODE_NETWORKING_SUPPORTED_KEY, isUserModeNetworkingSupported(version)); extensionApi.context.setValue(PODMAN_PROVIDER_LIBKRUN_SUPPORTED_KEY, isLibkrunSupported(version)); + const wslHypervEnabled = (await isWSLEnabled()) && (await isHyperVEnabled()); + updateWSLHyperVEnabledValue(wslHypervEnabled); isMovedPodmanSocket = isPodmanSocketLocationMoved(version); } @@ -1492,11 +1499,18 @@ export async function activate(extensionContext: extensionApi.ExtensionContext): // create machines on Linux via Podman Desktop, however we will still support // the lifecycle management of one. if (isMac() || isWindows()) { - provider.setContainerProviderConnectionFactory({ - initialize: () => createMachine({}), - create: createMachine, - creationDisplayName: 'Podman machine', - }); + provider.setContainerProviderConnectionFactory( + { + initialize: () => createMachine({}), + create: createMachine, + creationDisplayName: 'Podman machine', + }, + { + auditItems: async (items: extensionApi.AuditRequestItems) => { + return await connectionAuditor(items); + }, + }, + ); } // Linux has native container support (no need for Podman Machine), so we don't need to create machines. @@ -1669,7 +1683,7 @@ export async function activate(extensionContext: extensionApi.ExtensionContext): const registrySetup = new RegistrySetup(); await registrySetup.setup(); - await calcPodmanMachineSetting(podmanConfiguration); + await calcPodmanMachineSetting(); const podmanRemoteConnections = new PodmanRemoteConnections(extensionContext, provider); podmanRemoteConnections.start(); @@ -1679,17 +1693,30 @@ export async function activate(extensionContext: extensionApi.ExtensionContext): }; } -export async function calcPodmanMachineSetting(podmanConfiguration: PodmanConfiguration): Promise { +async function connectionAuditor(items: extensionApi.AuditRequestItems): Promise { + const records: extensionApi.AuditRecord[] = []; + const auditResult = { + records: records, + }; + const winProvider = items['podman.factory.machine.win.provider']; + const isWSL = winProvider === 'wsl'; + if (createWSLMachineOptionSelected !== isWSL) { + createWSLMachineOptionSelected = isWSL; + extensionApi.context.setValue(CREATE_WSL_MACHINE_OPTION_SELECTED_KEY, createWSLMachineOptionSelected); + } + return auditResult; +} + +export async function calcPodmanMachineSetting(): Promise { let cpuSupported = true; let memorySupported = true; let diskSupported = true; if (isWindows()) { - const isPodmanHyperv_Env = process.env.CONTAINERS_MACHINE_PROVIDER === 'hyperv'; - const isPodmanHyperv_Config = await podmanConfiguration.matchRegexpInContainersConfig(/provider\s*=\s*"hyperv"/); - cpuSupported = isPodmanHyperv_Env || isPodmanHyperv_Config; - memorySupported = isPodmanHyperv_Env || isPodmanHyperv_Config; - diskSupported = isPodmanHyperv_Env || isPodmanHyperv_Config; + const isHyperV = await isHyperVEnabled(); + cpuSupported = isHyperV; + memorySupported = isHyperV; + diskSupported = isHyperV; } extensionApi.context.setValue(PODMAN_MACHINE_CPU_SUPPORTED_KEY, cpuSupported); @@ -1745,12 +1772,18 @@ export async function getJSONMachineList(): Promise { containerMachineProviders.push(...['applehv', 'libkrun']); } + let wslEnabled = false; + let hypervEnabled = false; if (await isWSLEnabled()) { + wslEnabled = true; containerMachineProviders.push('wsl'); } if (await isHyperVEnabled()) { + hypervEnabled = true; containerMachineProviders.push('hyperv'); } + // update context "wsl-hyperv enabled" value + updateWSLHyperVEnabledValue(wslEnabled && hypervEnabled); if (containerMachineProviders.length === 0) { // in all other cases we set undefined so that it executes normally by using the default container provider @@ -1937,6 +1970,9 @@ export async function createMachine( if (params['podman.factory.machine.provider']) { provider = getProviderByLabel(params['podman.factory.machine.provider']); telemetryRecords.provider = provider; + } else if (params['podman.factory.machine.win.provider']) { + provider = params['podman.factory.machine.win.provider']; + telemetryRecords.provider = provider; } // cpus @@ -1955,7 +1991,9 @@ export async function createMachine( if (params['podman.factory.machine.memory']) { parameters.push('--memory'); const memoryAsMiB = +params['podman.factory.machine.memory'] / (1024 * 1024); - parameters.push(Math.floor(memoryAsMiB).toString()); + // Hyper-V requires VMs to have memory in 2 MB increments. So we round it + const roundedMemoryMiB = Math.floor((memoryAsMiB + 1) / 2) * 2; + parameters.push(roundedMemoryMiB.toString()); telemetryRecords.memory = params['podman.factory.machine.memory']; } @@ -2161,3 +2199,10 @@ export async function handleCompatibilityModeSetting(): Promise { await socketCompatibilityMode.disable(); } } + +export function updateWSLHyperVEnabledValue(value: boolean): void { + if (wslAndHypervEnabled !== value) { + wslAndHypervEnabled = value; + extensionApi.context.setValue(WSL_HYPERV_ENABLED_KEY, value); + } +} diff --git a/packages/renderer/src/lib/preferences/PreferencesConnectionCreationOrEditRendering.svelte b/packages/renderer/src/lib/preferences/PreferencesConnectionCreationOrEditRendering.svelte index f4cd182382c8d..59e3bae70b511 100644 --- a/packages/renderer/src/lib/preferences/PreferencesConnectionCreationOrEditRendering.svelte +++ b/packages/renderer/src/lib/preferences/PreferencesConnectionCreationOrEditRendering.svelte @@ -96,7 +96,10 @@ onMount(async () => { osMemory = await window.getOsMemory(); osCpu = await window.getOsCpu(); osFreeDisk = await window.getOsFreeDiskSize(); - contextsUnsubscribe = context.subscribe(value => (globalContext = value)); + contextsUnsubscribe = context.subscribe(value => { + globalContext = value; + loadConnectionParams().catch(() => console.error('unable to reload connection params')); + }); // check if we have an existing action const operationConnectionInfoMap = get(operationConnectionsInfo); @@ -119,6 +122,37 @@ onMount(async () => { } } + if (taskId === undefined) { + taskId = operationConnectionInfoMap.size + 1; + } + + const data: any = {}; + for (let field of configurationKeys) { + const id = field.id; + if (id) { + data[id] = field.default; + } + } + if (!connectionInfo) { + try { + connectionAuditResult = await window.auditConnectionParameters(providerInfo.internalId, data); + } catch (e: any) { + console.warn(e.message); + } + } + pageIsLoading = false; +}); + +onDestroy(() => { + if (loggerHandlerKey) { + disconnectUI(loggerHandlerKey); + } + if (contextsUnsubscribe) { + contextsUnsubscribe(); + } +}); + +async function loadConnectionParams() { configurationKeys = properties .filter(property => Array.isArray(property.scope) ? property.scope.find(s => s === propertyScope) : property.scope === propertyScope, @@ -165,36 +199,7 @@ onMount(async () => { if (connectionInfo) { configurationKeys = configurationKeys.filter(property => !property.readonly); } - - if (taskId === undefined) { - taskId = operationConnectionInfoMap.size + 1; - } - - const data: any = {}; - for (let field of configurationKeys) { - const id = field.id; - if (id) { - data[id] = field.default; - } - } - if (!connectionInfo) { - try { - connectionAuditResult = await window.auditConnectionParameters(providerInfo.internalId, data); - } catch (e: any) { - console.warn(e.message); - } - } - pageIsLoading = false; -}); - -onDestroy(() => { - if (loggerHandlerKey) { - disconnectUI(loggerHandlerKey); - } - if (contextsUnsubscribe) { - contextsUnsubscribe(); - } -}); +} function handleInvalidComponent() { isValid = false; @@ -228,8 +233,13 @@ async function handleValidComponent() { function internalSetConfigurationValue(id: string, modified: boolean, value: string | boolean | number) { const item = configurationValues.get(id); if (item) { - item.modified = modified; - item.value = value; + // if the value has already been modified by the user and this is not an explicit user modification we do not update the value + // it may happen that the UI refreshes for some reason (like a value chosen in a dropdownlist) and we do not have to reset the + // values already entered (modified = true) by the user + if (modified || !item.modified) { + item.modified = modified; + item.value = value; + } } else { configurationValues.set(id, { modified, value }); } @@ -414,15 +424,26 @@ function closePage() { function getConnectionResourceConfigurationValue( configurationKey: IConfigurationPropertyRecordedSchema, configurationValues: Map, -): number | undefined { +): string | boolean | number | undefined { if (configurationKey.id && configurationValues.has(configurationKey.id)) { const value = configurationValues.get(configurationKey.id); - if (typeof value?.value === 'number') { + if (value?.value !== undefined) { return value.value; } } return undefined; } + +function getConnectionResourceConfigurationNumberValue( + configurationKey: IConfigurationPropertyRecordedSchema, + configurationValues: Map, +): number | undefined { + const value = getConnectionResourceConfigurationValue(configurationKey, configurationValues); + if (typeof value === 'number') { + return value; + } + return undefined; +}
@@ -500,7 +521,7 @@ function getConnectionResourceConfigurationValue(
{/if} diff --git a/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.spec.ts b/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.spec.ts index 0568a77dd2548..d03542cbc4e62 100644 --- a/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.spec.ts +++ b/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.spec.ts @@ -109,6 +109,21 @@ test('Expect a checkbox when record is type boolean', async () => { expect((input as HTMLInputElement).name).toBe('record'); }); +test('Expect to see checkbox checked if givenValue is true', async () => { + const record: IConfigurationPropertyRecordedSchema = { + title: 'my boolean property', + id: 'myid', + parentId: '', + type: 'boolean', + default: false, + }; + // remove display name + await awaitRender(record, { givenValue: true }); + const button = screen.getByRole('checkbox'); + expect(button).toBeInTheDocument(); + expect(button).toBeChecked(); +}); + test('Expect a slider when record and its maximum are type number and enableSlider is true', async () => { const record: IConfigurationPropertyRecordedSchema = { id: 'record', @@ -201,6 +216,24 @@ test('Expect a fileinput when record is type string and format folder', async () expect(input).toBeInTheDocument(); }); +test('Expect a fileinput to be populated if the givenValue is defined', async () => { + const record: IConfigurationPropertyRecordedSchema = { + title: 'record', + parentId: 'parent.record', + placeholder: 'Example: text', + description: 'record-description', + type: 'string', + format: 'folder', + }; + await awaitRender(record, { givenValue: 'filename' }); + const readOnlyInput = screen.getByLabelText('record-description'); + expect(readOnlyInput).toBeInTheDocument(); + expect(readOnlyInput instanceof HTMLInputElement).toBe(true); + expect((readOnlyInput as HTMLInputElement).value).equals('filename'); + const input = screen.getByLabelText('browse'); + expect(input).toBeInTheDocument(); +}); + test('Expect a select when record is type string and has enum values', async () => { const record: IConfigurationPropertyRecordedSchema = { id: 'record', @@ -217,6 +250,23 @@ test('Expect a select when record is type string and has enum values', async () expect((input as HTMLSelectElement).name).toBe('record'); }); +test('Expect enum to have the givenValue selected', async () => { + const record: IConfigurationPropertyRecordedSchema = { + id: 'record', + title: 'record', + parentId: 'parent.record', + description: 'record-description', + type: 'string', + enum: ['first', 'second'], + }; + await awaitRender(record, { givenValue: 'second' }); + const input = screen.getByLabelText('record-description'); + expect(input).toBeInTheDocument(); + expect(input instanceof HTMLSelectElement).toBe(true); + expect((input as HTMLSelectElement).name).toBe('record'); + expect((input as HTMLSelectElement).value).toBe('second'); +}); + test('Expect a text input when record is type string', async () => { const record: IConfigurationPropertyRecordedSchema = { id: 'record', @@ -235,6 +285,24 @@ test('Expect a text input when record is type string', async () => { expect((input as HTMLInputElement).placeholder).toBe(record.placeholder); }); +test('Expect a text input filled with givenValue when defined', async () => { + const record: IConfigurationPropertyRecordedSchema = { + id: 'record', + title: 'record', + parentId: 'parent.record', + description: 'record-description', + placeholder: 'Example: text', + type: 'string', + }; + await awaitRender(record, { givenValue: 'fake' }); + const input = screen.getByLabelText('record-description'); + expect(input).toBeInTheDocument(); + expect(input instanceof HTMLInputElement).toBe(true); + expect((input as HTMLInputElement).type).toBe('text'); + expect((input as HTMLSelectElement).name).toBe('record'); + expect((input as HTMLInputElement).value).equals('fake'); +}); + test('Expect tooltip text shows info when input is less than minimum', async () => { const record: IConfigurationPropertyRecordedSchema = { id: 'record', diff --git a/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.svelte b/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.svelte index d73ca1816ba1a..ee1141570f757 100644 --- a/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.svelte +++ b/packages/renderer/src/lib/preferences/PreferencesRenderingItemFormat.svelte @@ -150,7 +150,10 @@ async function onChange(recordId: string, value: boolean | string | number): Pro {/if} {#if record.type === 'boolean'} - + {:else if record.type === 'number' || record.type === 'integer'} {#if enableSlider && typeof record.maximum === 'number'} + {:else if record.enum && record.enum.length > 0} - + {:else} - + {/if} {:else if record.type === 'markdown'}